-- api: shall-issue tenancy. Owners, keys, usage. -- -- Most sensitive schema in the database. hammy_readonly is deliberately NOT -- granted USAGE on it - a reporting query has no business reading key material. -- --------------------------------------------------------------------------- -- owners -- --------------------------------------------------------------------------- CREATE TABLE api.owners ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- The abuse contact, and the only field that is genuinely required. It is -- what makes shall-issue workable: a channel to say "your key is doing -- something odd, fix it or I revoke". email core.email NOT NULL UNIQUE, email_verified_at timestamptz, -- Optional, and a scope multiplier rather than a gate. An unlicensed -- developer building a study tool against the exam endpoints has no -- callsign and no reason to get one yet. callsign core.callsign, callsign_verified_at timestamptz, status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'closed')), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); COMMENT ON TABLE api.owners IS 'Registrants for API keys. Holds PII from an international user base, so the deletion path in 005_privacy.sql applies here too, not just to Discord users.'; CREATE UNIQUE INDEX owners_verified_callsign_uniq ON api.owners (callsign) WHERE callsign_verified_at IS NOT NULL; CREATE TRIGGER owners_touch BEFORE UPDATE ON api.owners FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at(); -- --------------------------------------------------------------------------- -- keys -- --------------------------------------------------------------------------- CREATE TABLE api.keys ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, owner_id bigint NOT NULL REFERENCES api.owners(id) ON DELETE CASCADE, -- SHA-256 of the key, computed in the application. The key itself is shown -- once at issue and never stored: a database dump must not be a set of -- working credentials. Hashing in the app rather than via pgcrypto keeps -- the plaintext out of the query log. key_hash bytea NOT NULL UNIQUE, CONSTRAINT keys_hash_is_sha256 CHECK (octet_length(key_hash) = 32), -- Enough of the key to identify it in a UI and in a leak report, e.g. -- 'hmy_live_a1b2c3d4'. Not secret and not sufficient to authenticate. key_prefix text NOT NULL, label text, -- Tier 0 reference data can be near-unlimited; callsign lookup and 44net -- probing need much tighter ceilings. Scoping from day one means a scope -- can be tightened later without breaking every key. scopes text[] NOT NULL DEFAULT '{}', CONSTRAINT keys_scopes_known CHECK ( scopes <@ ARRAY[ 'reference', -- band plans, prefixes, calculators 'callsign', -- callsign lookup, rate limited 'spots', -- POTA/SOTA/cluster/RBN feeds 'propagation', 'logbook', -- read/write a user's own log 'net44', -- reachability probes from the 44net allocation 'admin' ]::text[] ), quota_tier text NOT NULL DEFAULT 'default' CHECK (quota_tier IN ('default', 'verified', 'trusted', 'internal')), status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked', 'suspended')), created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz, last_used_at timestamptz, revoked_at timestamptz, revoked_reason text, CONSTRAINT keys_revoked_consistent CHECK ( (status = 'revoked') = (revoked_at IS NOT NULL) ) ); COMMENT ON TABLE api.keys IS 'Several active keys per owner is intentional: people leak keys into public repositories, and rotating needs an overlap window where both the old and new key work.'; CREATE INDEX keys_owner_idx ON api.keys (owner_id); -- No separate index on key_hash: the UNIQUE constraint above already builds a -- btree over exactly that column, and the planner uses it for the lookup. CREATE INDEX keys_expiring_idx ON api.keys (expires_at) WHERE status = 'active' AND expires_at IS NOT NULL; -- --------------------------------------------------------------------------- -- usage -- --------------------------------------------------------------------------- -- Rolled up from Redis, which is authoritative for the live rate-limit window. -- Redis holds the current counters; this holds completed days. A Redis flush -- loses at most the window in progress, which for abuse detection is fine. CREATE TABLE api.usage_daily ( key_id bigint NOT NULL REFERENCES api.keys(id) ON DELETE CASCADE, day date NOT NULL, endpoint text NOT NULL, calls bigint NOT NULL DEFAULT 0, errors bigint NOT NULL DEFAULT 0, -- Distinct end users seen, not who they were. Counting them is the -- enumeration signal - one Discord user driving 90% of a key's traffic -- looks very different from fifty users sharing it - but the identities -- themselves are not needed and so are not kept. distinct_users integer NOT NULL DEFAULT 0, PRIMARY KEY (key_id, day, endpoint) ); -- The flush job is an upsert: -- INSERT INTO api.usage_daily (key_id, day, endpoint, calls) -- VALUES ($1, $2, $3, $4) -- ON CONFLICT (key_id, day, endpoint) -- DO UPDATE SET calls = api.usage_daily.calls + EXCLUDED.calls; CREATE INDEX usage_daily_day_idx ON api.usage_daily (day);