mcp connection
This commit is contained in:
14
migrations/000012_oauth_clients.down.sql
Normal file
14
migrations/000012_oauth_clients.down.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Reverses 000012.
|
||||
--
|
||||
-- Dropping oauth_clients unregisters every connected MCP client. That is the
|
||||
-- correct meaning of rolling back OAuth client registration, and it destroys no
|
||||
-- application data: every row here is a registration, not a business record.
|
||||
-- A client whose registration is gone simply registers again.
|
||||
--
|
||||
-- 000013 and 000014 reference this table, so they must be rolled back first.
|
||||
-- golang-migrate applies down migrations in descending order, which does that
|
||||
-- by construction.
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
DROP TABLE IF EXISTS public.oauth_clients;
|
||||
86
migrations/000012_oauth_clients.up.sql
Normal file
86
migrations/000012_oauth_clients.up.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- ============================================================================
|
||||
-- Krow — OAuth 2.1 clients
|
||||
--
|
||||
-- Phase 3, migration 1 of 3. This table holds the clients that may ask for a
|
||||
-- token: in practice, one row per Claude installation that has connected.
|
||||
--
|
||||
-- Registered DYNAMICALLY (RFC 7591), not seeded. An MCP client discovers this
|
||||
-- server, registers itself, and gets a client_id back. There is deliberately no
|
||||
-- pre-provisioned row and no fixture: a seeded client is a credential in the
|
||||
-- repository, and the whole point of dynamic registration is that nobody has to
|
||||
-- put one there.
|
||||
--
|
||||
-- PUBLIC CLIENTS ONLY, and that is why there is no client_secret column.
|
||||
-- Claude Desktop and Claude Web are public clients — they run on a machine the
|
||||
-- user controls, so any secret shipped to them is a secret the user has. OAuth
|
||||
-- 2.1 handles this with PKCE instead, which is why code_challenge is mandatory
|
||||
-- in 000013 rather than optional. A column for a secret that must never be
|
||||
-- trusted is a column somebody will eventually trust.
|
||||
--
|
||||
-- Target schema: public. No system schema is read or written.
|
||||
-- ============================================================================
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
CREATE TABLE oauth_clients (
|
||||
-- The client_id handed back at registration and presented on every
|
||||
-- authorization and token request. Opaque and server-generated: a client
|
||||
-- that could choose its own id could impersonate one already registered.
|
||||
client_id text PRIMARY KEY,
|
||||
|
||||
-- What the client calls itself, for the consent screen. Untrusted display
|
||||
-- text — it is whatever the registering client sent, so it is shown as a
|
||||
-- name and never used for a decision.
|
||||
client_name text NOT NULL DEFAULT '',
|
||||
|
||||
-- Exact-match redirect targets. An array because a client may legitimately
|
||||
-- register more than one (a desktop loopback port and a hosted callback),
|
||||
-- and the authorization endpoint matches the presented redirect_uri against
|
||||
-- these byte-for-byte. No prefix matching, no wildcards, no normalisation:
|
||||
-- every one of those has been an open-redirect CVE somewhere.
|
||||
redirect_uris text[] NOT NULL,
|
||||
|
||||
-- Recorded for auditing which client asked for what. Constrained rather than
|
||||
-- free text so an unexpected value is a failed insert instead of a row
|
||||
-- nobody notices.
|
||||
grant_types text[] NOT NULL DEFAULT ARRAY['authorization_code', 'refresh_token'],
|
||||
|
||||
-- The scopes this client may request. Held per-client so tightening the
|
||||
-- global policy later does not silently widen an existing registration.
|
||||
scopes text[] NOT NULL DEFAULT ARRAY['krow.read'],
|
||||
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
last_used_at timestamptz,
|
||||
|
||||
-- A client can be disabled without deleting it, so its tokens can be
|
||||
-- revoked and its history kept.
|
||||
disabled_at timestamptz,
|
||||
|
||||
-- At least one redirect URI, or the client can never complete a flow. Caught
|
||||
-- here so a malformed registration fails at the point of registration rather
|
||||
-- than at the point a person is staring at a broken consent screen.
|
||||
CONSTRAINT oauth_clients_redirect_uris_present
|
||||
CHECK (array_length(redirect_uris, 1) >= 1),
|
||||
|
||||
-- Bound so a registration cannot be used to store bulk data.
|
||||
CONSTRAINT oauth_clients_redirect_uris_bounded
|
||||
CHECK (array_length(redirect_uris, 1) <= 10),
|
||||
|
||||
CONSTRAINT oauth_clients_name_bounded
|
||||
CHECK (length(client_name) <= 200)
|
||||
);
|
||||
|
||||
-- Listing a user's connected clients is not a query this table answers — that
|
||||
-- comes from oauth_tokens, which carries user_id. The only index here is the
|
||||
-- primary key, which is also the lookup path: every request arrives with a
|
||||
-- client_id and probes exactly that column.
|
||||
|
||||
COMMENT ON TABLE oauth_clients IS
|
||||
'OAuth 2.1 public clients, registered dynamically per RFC 7591. No secrets '
|
||||
'are stored: public clients authenticate with PKCE, not with a credential.';
|
||||
|
||||
COMMENT ON COLUMN oauth_clients.redirect_uris IS
|
||||
'Exact-match redirect targets. Never prefix-matched or normalised.';
|
||||
|
||||
COMMENT ON COLUMN oauth_clients.disabled_at IS
|
||||
'Set to disable a client without losing its registration or audit history.';
|
||||
11
migrations/000013_oauth_grants.down.sql
Normal file
11
migrations/000013_oauth_grants.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Reverses 000013.
|
||||
--
|
||||
-- Drops every outstanding authorization code. Any flow mid-redirect fails and
|
||||
-- the user reauthorizes, which is the correct meaning of rolling this back: a
|
||||
-- row here is a credential in flight, not a record of anything.
|
||||
--
|
||||
-- No touch to oauth_clients, which 000012 owns.
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
DROP TABLE IF EXISTS public.oauth_grants;
|
||||
117
migrations/000013_oauth_grants.up.sql
Normal file
117
migrations/000013_oauth_grants.up.sql
Normal file
@@ -0,0 +1,117 @@
|
||||
-- ============================================================================
|
||||
-- Krow — OAuth 2.1 authorization codes
|
||||
--
|
||||
-- Phase 3, migration 2 of 3. One row per authorization code issued: the short
|
||||
-- window between a person clicking Approve and the client exchanging the code
|
||||
-- for a token.
|
||||
--
|
||||
-- A row here is a bearer credential with a fuse. Three properties make it safe,
|
||||
-- and all three are enforced by this schema rather than by the code that uses
|
||||
-- it:
|
||||
--
|
||||
-- SINGLE-USE consumed_at, set by the same UPDATE that reads the row. A
|
||||
-- code redeemed twice is an attacker replaying a code they
|
||||
-- intercepted, and the second attempt must fail.
|
||||
-- SHORT-LIVED expires_at, minutes not hours. The code is in transit through
|
||||
-- a browser redirect, which is the least trustworthy hop in the
|
||||
-- flow.
|
||||
-- BOUND to client, redirect_uri, user, scope, resource and PKCE
|
||||
-- challenge. Every one of those is re-verified at the token
|
||||
-- endpoint, so a code stolen from one context cannot be spent
|
||||
-- in another.
|
||||
--
|
||||
-- THE CODE ITSELF IS NEVER STORED. code_hash holds SHA-256, exactly as
|
||||
-- sessions.token_hash does, so a dump of this table cannot be replayed.
|
||||
--
|
||||
-- Target schema: public. No system schema is read or written.
|
||||
-- ============================================================================
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
CREATE TABLE oauth_grants (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- SHA-256 of the authorization code, lowercase hex. The CHECK pins the
|
||||
-- format so a caller cannot accidentally store a raw code here: a raw code is
|
||||
-- base64url of random bytes and fails this pattern. Same guard, same
|
||||
-- reasoning as sessions_token_hash_sha256 in 000004.
|
||||
code_hash text NOT NULL,
|
||||
|
||||
client_id text NOT NULL REFERENCES oauth_clients (client_id) ON DELETE CASCADE,
|
||||
|
||||
-- Who approved. ON DELETE CASCADE: a deleted user must not leave a code
|
||||
-- behind that could still be exchanged for a token authenticating as them.
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
|
||||
-- The tenant, denormalised from the user row at issue time. Carried for
|
||||
-- auditing only. It is NEVER read back as the authority on tenancy —
|
||||
-- identity is rebuilt from the live user row at every token validation, so a
|
||||
-- user who moved organisation does not keep the old one. See
|
||||
-- oauth.Authenticator.
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
|
||||
-- Re-verified at the token endpoint. RFC 6749 requires the redirect_uri
|
||||
-- presented at exchange to match the one presented at authorization; without
|
||||
-- this column there is nothing to match against.
|
||||
redirect_uri text NOT NULL,
|
||||
|
||||
scopes text[] NOT NULL,
|
||||
|
||||
-- RFC 8707. The MCP server this code is being obtained for. Carried into the
|
||||
-- access token's audience, which is what makes a token issued for one
|
||||
-- resource unusable at another.
|
||||
resource text NOT NULL,
|
||||
|
||||
-- PKCE. Mandatory — the column is NOT NULL, so a code without a challenge
|
||||
-- cannot exist. OAuth 2.1 requires PKCE for public clients and this is where
|
||||
-- that requirement stops being advisory.
|
||||
code_challenge text NOT NULL,
|
||||
code_challenge_method text NOT NULL,
|
||||
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
|
||||
-- Set on redemption, in the same statement that reads the row. NULL means
|
||||
-- unspent.
|
||||
consumed_at timestamptz,
|
||||
|
||||
CONSTRAINT oauth_grants_code_hash_key UNIQUE (code_hash),
|
||||
CONSTRAINT oauth_grants_code_hash_sha256 CHECK (code_hash ~ '^[0-9a-f]{64}$'),
|
||||
|
||||
-- S256 only. `plain` is permitted by RFC 7636 and forbidden by OAuth 2.1 for
|
||||
-- public clients, because it makes the verifier recoverable from the
|
||||
-- challenge — which is the entire attack PKCE exists to stop. Refused at the
|
||||
-- schema level so no code path can relax it.
|
||||
CONSTRAINT oauth_grants_pkce_s256_only CHECK (code_challenge_method = 'S256'),
|
||||
|
||||
-- A challenge is base64url of a 32-byte SHA-256 digest: 43 characters, no
|
||||
-- padding. Anything else is malformed.
|
||||
CONSTRAINT oauth_grants_challenge_shape CHECK (code_challenge ~ '^[A-Za-z0-9_-]{43}$'),
|
||||
|
||||
CONSTRAINT oauth_grants_expires_after_created CHECK (expires_at > created_date),
|
||||
CONSTRAINT oauth_grants_scopes_present CHECK (array_length(scopes, 1) >= 1)
|
||||
);
|
||||
|
||||
-- The redemption path: look up by hash, check unspent and unexpired. The UNIQUE
|
||||
-- constraint above already provides this index.
|
||||
|
||||
-- The sweep of dead rows.
|
||||
CREATE INDEX oauth_grants_expires_idx ON oauth_grants (expires_at);
|
||||
|
||||
-- Revoking every outstanding code for a user, and the FK's own cascade check.
|
||||
CREATE INDEX oauth_grants_user_idx ON oauth_grants (user_id);
|
||||
|
||||
COMMENT ON TABLE oauth_grants IS
|
||||
'OAuth authorization codes: single-use, short-lived, and bound to client, '
|
||||
'redirect_uri, user, scope, resource and PKCE challenge. The raw code is '
|
||||
'never stored — only SHA-256 of it.';
|
||||
|
||||
COMMENT ON COLUMN oauth_grants.code_hash IS
|
||||
'Lowercase hex SHA-256 of the authorization code. Never the code.';
|
||||
|
||||
COMMENT ON COLUMN oauth_grants.consumed_at IS
|
||||
'Set by the redemption UPDATE itself, so a code cannot be spent twice.';
|
||||
|
||||
COMMENT ON COLUMN oauth_grants.org_id IS
|
||||
'The tenant at issue time, for audit only. Tenancy is re-read from the live '
|
||||
'user row on every token validation and is never taken from here.';
|
||||
14
migrations/000014_oauth_tokens.down.sql
Normal file
14
migrations/000014_oauth_tokens.down.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Reverses 000014.
|
||||
--
|
||||
-- Drops every access and refresh token, disconnecting every connected MCP
|
||||
-- client. Users reconnect through the normal authorization flow.
|
||||
--
|
||||
-- This destroys no application data: every row is a credential. Cookie sessions
|
||||
-- are in `sessions` and are untouched, so the merchant-facing product and the
|
||||
-- existing console keep working exactly as before.
|
||||
--
|
||||
-- No touch to oauth_clients or oauth_grants, which 000012 and 000013 own.
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
DROP TABLE IF EXISTS public.oauth_tokens;
|
||||
121
migrations/000014_oauth_tokens.up.sql
Normal file
121
migrations/000014_oauth_tokens.up.sql
Normal file
@@ -0,0 +1,121 @@
|
||||
-- ============================================================================
|
||||
-- Krow — OAuth 2.1 access and refresh tokens
|
||||
--
|
||||
-- Phase 3, migration 3 of 3. One row per issued token, access and refresh
|
||||
-- alike, because they share every lifecycle question worth asking: is it known,
|
||||
-- has it expired, has it been revoked, whose is it, and what may it reach.
|
||||
--
|
||||
-- THE RAW TOKEN IS NEVER STORED. token_hash holds SHA-256, and the CHECK below
|
||||
-- refuses anything that is not 64 hex characters — so a raw token, which is
|
||||
-- base64url of random bytes, cannot physically be written to this column. This
|
||||
-- mirrors sessions.token_hash from 000004 exactly, and for the same reason: a
|
||||
-- dump of this table must not be replayable as a login.
|
||||
--
|
||||
-- TOKEN FAMILIES AND REUSE DETECTION
|
||||
--
|
||||
-- Refresh tokens rotate: spending one issues its replacement and consumes the
|
||||
-- old one. family_id ties a lineage together, which is what makes theft
|
||||
-- detectable. If a consumed refresh token is presented again, either the
|
||||
-- legitimate client is retrying or an attacker is replaying a stolen token, and
|
||||
-- there is no way to tell which. OAuth 2.1's answer is to assume the worse case
|
||||
-- and revoke the entire family — the attacker loses access, and the legitimate
|
||||
-- client is forced through a fresh authorization it can complete. Without
|
||||
-- family_id the best available response is to revoke nothing.
|
||||
--
|
||||
-- Target schema: public. No system schema is read or written.
|
||||
-- ============================================================================
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
CREATE TABLE oauth_tokens (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- SHA-256 of the token, lowercase hex. Never the token.
|
||||
token_hash text NOT NULL,
|
||||
|
||||
-- 'access' or 'refresh'. One table, because the questions asked of both are
|
||||
-- the same; a type column rather than two tables, because a lookup that had
|
||||
-- to try two tables would eventually try only one.
|
||||
token_type text NOT NULL,
|
||||
|
||||
-- Rotation lineage. Every token minted from the same authorization shares a
|
||||
-- family, so reuse detection can revoke all of them at once.
|
||||
family_id uuid NOT NULL,
|
||||
|
||||
client_id text NOT NULL REFERENCES oauth_clients (client_id) ON DELETE CASCADE,
|
||||
|
||||
-- ON DELETE CASCADE: a deleted user must not leave a live token behind.
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
|
||||
-- Denormalised at issue time for auditing and for cheap per-tenant queries
|
||||
-- ("what is this organisation's Claude usage"). NEVER the authority on
|
||||
-- tenancy: identity is rebuilt from the live user row at validation, so a
|
||||
-- suspended or moved user is caught on their next call rather than at expiry.
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
|
||||
scopes text[] NOT NULL,
|
||||
|
||||
-- RFC 8707. The MCP server this token is for. Validation requires it to
|
||||
-- match this deployment's canonical resource URI, which is what stops a
|
||||
-- token minted for another service being spent here.
|
||||
audience text NOT NULL,
|
||||
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- Sliding is not a concept here: an access token expires and the client
|
||||
-- refreshes. expires_at is the only deadline an access token has.
|
||||
expires_at timestamptz NOT NULL,
|
||||
|
||||
-- Set when a refresh token is spent. A consumed refresh token presented
|
||||
-- again is the reuse signal that revokes the family.
|
||||
consumed_at timestamptz,
|
||||
|
||||
-- Set by revocation: an explicit disconnect, a family revocation, or a
|
||||
-- suspended account being cleaned up.
|
||||
revoked_at timestamptz,
|
||||
revoked_reason text,
|
||||
|
||||
last_used_at timestamptz,
|
||||
|
||||
CONSTRAINT oauth_tokens_token_hash_key UNIQUE (token_hash),
|
||||
CONSTRAINT oauth_tokens_token_hash_sha256 CHECK (token_hash ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT oauth_tokens_type_check CHECK (token_type IN ('access', 'refresh')),
|
||||
CONSTRAINT oauth_tokens_expires_after_created CHECK (expires_at > created_date),
|
||||
CONSTRAINT oauth_tokens_scopes_present CHECK (array_length(scopes, 1) >= 1),
|
||||
CONSTRAINT oauth_tokens_audience_present CHECK (length(btrim(audience)) > 0)
|
||||
);
|
||||
|
||||
-- Validation reads by hash on every single MCP request; the UNIQUE constraint
|
||||
-- above is that index.
|
||||
|
||||
-- Reuse detection and family revocation: given one token, revoke its lineage.
|
||||
CREATE INDEX oauth_tokens_family_idx ON oauth_tokens (family_id);
|
||||
|
||||
-- "Which apps has this user connected", and revoking everything for a user
|
||||
-- whose account was suspended. Partial, because revoked rows are never the
|
||||
-- answer to either question.
|
||||
CREATE INDEX oauth_tokens_user_active_idx ON oauth_tokens (user_id)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
-- The sweep of expired rows.
|
||||
CREATE INDEX oauth_tokens_expires_idx ON oauth_tokens (expires_at);
|
||||
|
||||
COMMENT ON TABLE oauth_tokens IS
|
||||
'OAuth access and refresh tokens. Stores SHA-256 of each token and never the '
|
||||
'token itself. family_id ties a rotation lineage together so that replay of a '
|
||||
'consumed refresh token can revoke the whole family.';
|
||||
|
||||
COMMENT ON COLUMN oauth_tokens.token_hash IS
|
||||
'Lowercase hex SHA-256 of the token. Never the token.';
|
||||
|
||||
COMMENT ON COLUMN oauth_tokens.family_id IS
|
||||
'Rotation lineage. Presenting a consumed refresh token revokes every row '
|
||||
'sharing this id — the OAuth 2.1 response to a possible stolen token.';
|
||||
|
||||
COMMENT ON COLUMN oauth_tokens.audience IS
|
||||
'RFC 8707 resource indicator. Must match this deployment''s canonical MCP '
|
||||
'resource URI at validation, or the token is refused.';
|
||||
|
||||
COMMENT ON COLUMN oauth_tokens.org_id IS
|
||||
'The tenant at issue time, for audit and reporting only. Tenancy is re-read '
|
||||
'from the live user row on every validation and is never taken from here.';
|
||||
13
migrations/000015_rate_limits.down.sql
Normal file
13
migrations/000015_rate_limits.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Reverses 000015.
|
||||
--
|
||||
-- Dropping the counters removes every in-flight rate limit window. The effect
|
||||
-- is that limits reset once, which is the correct meaning of rolling back a
|
||||
-- counter table: it destroys no application data, and a caller who was at their
|
||||
-- limit gets a fresh window rather than a permanent refusal.
|
||||
--
|
||||
-- The in-process limiter in httpserver/ratelimit.go is untouched by this
|
||||
-- migration and by its rollback; login limiting keeps working either way.
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
DROP TABLE IF EXISTS public.rate_limits;
|
||||
81
migrations/000015_rate_limits.up.sql
Normal file
81
migrations/000015_rate_limits.up.sql
Normal file
@@ -0,0 +1,81 @@
|
||||
-- ============================================================================
|
||||
-- Krow — rate limit counters
|
||||
--
|
||||
-- Phase 5. One row per (bucket, window), counting requests.
|
||||
--
|
||||
-- WHY POSTGRES AND NOT REDIS
|
||||
--
|
||||
-- The existing limiter (httpserver/ratelimit.go) is an in-process map, which
|
||||
-- has a failure mode that is easy to miss: behind N instances the effective
|
||||
-- limit is N times the configured one, because each instance counts only what
|
||||
-- it saw. A limit that silently multiplies by the replica count is not a limit.
|
||||
--
|
||||
-- Redis would work and is the conventional answer. It is not the right answer
|
||||
-- here: this service has exactly one piece of shared infrastructure, and adding
|
||||
-- a second means another thing to run, monitor, secure and fail over — for a
|
||||
-- counter. Postgres already provides the one primitive this needs, an atomic
|
||||
-- read-modify-write, in a single statement:
|
||||
--
|
||||
-- INSERT … ON CONFLICT (bucket, window_start) DO UPDATE
|
||||
-- SET count = rate_limits.count + 1
|
||||
-- RETURNING count
|
||||
--
|
||||
-- That is correct under concurrency without a transaction, without a lock taken
|
||||
-- in application code, and without a round trip to decide anything.
|
||||
--
|
||||
-- FIXED WINDOWS, NOT A SLIDING LOG
|
||||
--
|
||||
-- A sliding window is more accurate and costs a row per request. A fixed window
|
||||
-- costs one row per bucket per window and admits a known burst — up to 2× the
|
||||
-- limit across a window boundary. For abuse prevention that is an acceptable
|
||||
-- trade, and it is the difference between a counter table and an append-only
|
||||
-- log nobody wants to sweep.
|
||||
--
|
||||
-- NO RAW CREDENTIAL IS EVER A BUCKET KEY. Callers hash anything sensitive
|
||||
-- before it reaches this table — see internal/ratelimit. A bucket naming a
|
||||
-- token would put that token in a table, in a log, and in every EXPLAIN a
|
||||
-- developer ever runs.
|
||||
--
|
||||
-- Target schema: public. No system schema is read or written.
|
||||
-- ============================================================================
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
CREATE TABLE rate_limits (
|
||||
-- The thing being limited: a scope prefix and an already-hashed subject,
|
||||
-- e.g. "oauth.register:ip:<sha256>" or "mcp.call:token:<sha256>".
|
||||
bucket text NOT NULL,
|
||||
|
||||
-- The window this count belongs to, truncated to the window size. Part of
|
||||
-- the key rather than a column to compare, so a new window is a new row and
|
||||
-- expiry is "delete old rows" rather than "reset a counter" — which means
|
||||
-- two instances rolling over at once cannot lose each other's increments.
|
||||
window_start timestamptz NOT NULL,
|
||||
|
||||
count integer NOT NULL DEFAULT 0,
|
||||
|
||||
-- When this row may be deleted. Carried explicitly rather than derived from
|
||||
-- window_start plus a duration the cleanup would have to know, so windows of
|
||||
-- different sizes can share one table and one sweep.
|
||||
expires_at timestamptz NOT NULL,
|
||||
|
||||
PRIMARY KEY (bucket, window_start),
|
||||
|
||||
CONSTRAINT rate_limits_count_non_negative CHECK (count >= 0),
|
||||
CONSTRAINT rate_limits_expires_after_window CHECK (expires_at > window_start)
|
||||
);
|
||||
|
||||
-- The sweep. Ordered by the column it filters on, so deleting a batch is a
|
||||
-- range scan rather than a sequential scan of every live counter.
|
||||
CREATE INDEX rate_limits_expires_idx ON rate_limits (expires_at);
|
||||
|
||||
COMMENT ON TABLE rate_limits IS
|
||||
'Fixed-window rate limit counters, shared across API instances. Incremented '
|
||||
'with a single atomic INSERT … ON CONFLICT DO UPDATE … RETURNING.';
|
||||
|
||||
COMMENT ON COLUMN rate_limits.bucket IS
|
||||
'Scope plus an ALREADY-HASHED subject. Never a raw token, code or password.';
|
||||
|
||||
COMMENT ON COLUMN rate_limits.window_start IS
|
||||
'Start of the fixed window, truncated to its size. Part of the key so a new '
|
||||
'window is a new row rather than a reset of an existing counter.';
|
||||
Reference in New Issue
Block a user