Files
hammy-backend/migrations/003_api.sql
T
2026-09-05 19:57:41 +02:00

135 lines
5.6 KiB
SQL

-- 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);