135 lines
5.6 KiB
SQL
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);
|