Files
William Gill 12bf35caf8 anchorage v1.0 initial tree
Greenfield Go multi-tenant IPFS Pinning Service wire-compatible with the
IPFS Pinning Services API spec. Paired 1:1 with Kubo over localhost RPC,
clustered via embedded NATS JetStream, Postgres source-of-truth with
RLS-enforced tenancy, Fiber + huma v2 for the HTTP surface, Authentik
OIDC for session login with kid-rotated HS256 JWT API tokens.

Feature-complete against the 22-milestone build plan, including the
ship-it v1.0 gap items:

  * admin CLIs: drain/uncordon, maintenance, mint-token, rotate-key,
    prune-denylist, rebalance --dry-run, cache-stats, cluster-presences
  * TTL leader election via NATS KV, fence tokens, JetStream dedup
  * rebalancer (plan/apply split), reconciler, requeue sweeper
  * ristretto caches with NATS-backed cross-node invalidation
    (placements live-nodes + token denylist)
  * maintenance watchdog for stuck cluster-pause flag
  * Prometheus /metrics with CIDR ACL, HTTP/pin/scheduler/cache gauges
  * rate limiting: session (10/min) + anonymous global (120/min)
  * integration tests: rebalance, refcount multi-org, RLS belt
  * goreleaser (tar + deb/rpm/apk + Alpine Docker) targeting Gitea

Stack: Cobra/Viper, Fiber v2 + huma v2, embedded NATS JetStream,
pgx/sqlc/golang-migrate, ristretto, TypeID, prometheus/client_golang,
testcontainers-go.
2026-04-16 18:13:36 -05:00

218 lines
10 KiB
PL/PgSQL

-- anchorage initial schema.
--
-- Every tenant-scoped table carries org_id and enables row-level security
-- so a Go-layer bug cannot bleed rows across orgs. The application sets
-- current_setting('anchorage.org_id') per-transaction via the auth middleware.
BEGIN;
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ---------------------------------------------------------------------------
-- orgs
-- ---------------------------------------------------------------------------
CREATE TABLE orgs (
id text PRIMARY KEY CHECK (id LIKE 'org_%'),
slug text NOT NULL UNIQUE,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- users
-- ---------------------------------------------------------------------------
CREATE TABLE users (
id text PRIMARY KEY CHECK (id LIKE 'usr_%'),
authentik_sub text UNIQUE,
email citext NOT NULL UNIQUE,
display_name text NOT NULL DEFAULT '',
is_sysadmin boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- memberships
-- ---------------------------------------------------------------------------
CREATE TABLE memberships (
org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('orgadmin', 'member')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (org_id, user_id)
);
CREATE INDEX memberships_user_idx ON memberships (user_id);
-- ---------------------------------------------------------------------------
-- api_tokens — JWT metadata only; the signed token is never stored
-- ---------------------------------------------------------------------------
CREATE TABLE api_tokens (
jti text PRIMARY KEY CHECK (jti LIKE 'tok_%'),
org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label text NOT NULL,
scopes text[] NOT NULL DEFAULT '{}',
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
last_used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX api_tokens_org_idx ON api_tokens (org_id, created_at DESC);
CREATE INDEX api_tokens_expiry_idx ON api_tokens (expires_at) WHERE revoked_at IS NULL;
-- ---------------------------------------------------------------------------
-- token_denylist — explicit revocation cache, TTL'd by expires_at
-- ---------------------------------------------------------------------------
CREATE TABLE token_denylist (
jti text PRIMARY KEY,
expires_at timestamptz NOT NULL,
reason text NOT NULL DEFAULT 'revoked',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX token_denylist_expiry_idx ON token_denylist (expires_at);
-- ---------------------------------------------------------------------------
-- nodes — cluster registry populated on boot and maintained by heartbeats
-- ---------------------------------------------------------------------------
CREATE TABLE nodes (
id text PRIMARY KEY CHECK (id LIKE 'nod_%'),
display_name text NOT NULL DEFAULT '',
multiaddrs text[] NOT NULL DEFAULT '{}',
rpc_url text NOT NULL,
status text NOT NULL CHECK (status IN ('up', 'down', 'drained')),
last_seen_at timestamptz NOT NULL DEFAULT now(),
joined_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX nodes_status_idx ON nodes (status, last_seen_at DESC);
-- ---------------------------------------------------------------------------
-- pins — logical pin records (spec: PinStatus)
-- ---------------------------------------------------------------------------
CREATE TABLE pins (
request_id text PRIMARY KEY CHECK (request_id LIKE 'pin_%'),
org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
cid text NOT NULL,
name text,
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
origins text[] NOT NULL DEFAULT '{}',
status text NOT NULL CHECK (status IN ('queued', 'pinning', 'pinned', 'failed')),
failure_reason text,
created timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX pins_org_created_idx ON pins (org_id, created DESC);
CREATE INDEX pins_org_status_created_idx ON pins (org_id, status, created DESC);
CREATE INDEX pins_org_cid_idx ON pins (org_id, cid);
CREATE INDEX pins_org_lower_name_idx ON pins (org_id, lower(name) text_pattern_ops);
CREATE INDEX pins_name_trgm_idx ON pins USING gin (lower(name) gin_trgm_ops);
CREATE INDEX pins_meta_gin_idx ON pins USING gin (meta jsonb_path_ops);
-- Idempotent create: `POST /v1/pins` with the same (org_id, cid) collapses
-- to the existing live row. Failed pins may be retried with the same CID.
CREATE UNIQUE INDEX pins_org_cid_live_uniq
ON pins (org_id, cid) WHERE status <> 'failed';
-- ---------------------------------------------------------------------------
-- pin_placements — per-node scheduling rows; fence guards against zombies
-- ---------------------------------------------------------------------------
CREATE TABLE pin_placements (
request_id text NOT NULL REFERENCES pins(request_id) ON DELETE CASCADE,
node_id text NOT NULL REFERENCES nodes(id) ON DELETE RESTRICT,
status text NOT NULL CHECK (status IN ('queued', 'pinning', 'pinned', 'failed')),
failure_reason text,
attempts int NOT NULL DEFAULT 0,
fence bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (request_id, node_id)
);
CREATE INDEX pin_placements_node_status_idx ON pin_placements (node_id, status);
CREATE INDEX pin_placements_request_id_idx ON pin_placements (request_id);
-- ---------------------------------------------------------------------------
-- pin_refcount — per-(node, cid) reference counting; row deletion = unpin
-- ---------------------------------------------------------------------------
CREATE TABLE pin_refcount (
node_id text NOT NULL REFERENCES nodes(id) ON DELETE RESTRICT,
cid text NOT NULL,
count int NOT NULL CHECK (count >= 0),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (node_id, cid)
);
-- ---------------------------------------------------------------------------
-- audit_log — append-only; bigserial is fine here (not user-facing)
-- ---------------------------------------------------------------------------
CREATE TABLE audit_log (
id bigserial PRIMARY KEY,
org_id text REFERENCES orgs(id) ON DELETE CASCADE,
actor_user_id text REFERENCES users(id) ON DELETE SET NULL,
actor_token_jti text,
action text NOT NULL,
target text NOT NULL,
result text NOT NULL CHECK (result IN ('ok', 'error')),
detail jsonb NOT NULL DEFAULT '{}'::jsonb,
created timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX audit_log_org_created_idx ON audit_log (org_id, created DESC);
CREATE INDEX audit_log_actor_created_idx ON audit_log (actor_user_id, created DESC);
-- ---------------------------------------------------------------------------
-- updated_at trigger
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION anchorage_touch_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
CREATE TRIGGER orgs_touch BEFORE UPDATE ON orgs FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at();
CREATE TRIGGER users_touch BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at();
CREATE TRIGGER memberships_touch BEFORE UPDATE ON memberships FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at();
CREATE TRIGGER api_tokens_touch BEFORE UPDATE ON api_tokens FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at();
CREATE TRIGGER nodes_touch BEFORE UPDATE ON nodes FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at();
CREATE TRIGGER pins_touch BEFORE UPDATE ON pins FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at();
CREATE TRIGGER pin_placements_touch BEFORE UPDATE ON pin_placements FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at();
CREATE TRIGGER pin_refcount_touch BEFORE UPDATE ON pin_refcount FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at();
-- ---------------------------------------------------------------------------
-- Row-level security — belt-and-suspenders tenant isolation
-- ---------------------------------------------------------------------------
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE api_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE pins ENABLE ROW LEVEL SECURITY;
-- current_setting with the second arg = true returns NULL for unset keys
-- instead of raising. An unset GUC means "no tenant context" and returns
-- zero rows, which is the safe default.
CREATE POLICY memberships_tenant_isolation ON memberships
FOR ALL
USING (org_id = current_setting('anchorage.org_id', true))
WITH CHECK (org_id = current_setting('anchorage.org_id', true));
CREATE POLICY api_tokens_tenant_isolation ON api_tokens
FOR ALL
USING (org_id = current_setting('anchorage.org_id', true))
WITH CHECK (org_id = current_setting('anchorage.org_id', true));
CREATE POLICY pins_tenant_isolation ON pins
FOR ALL
USING (org_id = current_setting('anchorage.org_id', true))
WITH CHECK (org_id = current_setting('anchorage.org_id', true));
COMMIT;