Files
saas_backend/alembic/versions/a7d9f1c3e80b_api_keys.py
T
2026-08-31 20:39:41 -04:00

91 lines
3.9 KiB
Python

"""Keys a customer can automate against.
Every integration a customer builds today has to hold a **person's password**,
sign in as them, and keep a session alive. That is worse than it sounds: the
credential cannot be scoped down, cannot be rotated without locking somebody out
of their own account, and cannot be told apart from that person in the audit
trail. When they leave, either the integration breaks or their account is kept
alive after they have gone.
An API key is a credential that belongs to the integration.
**Stored as a SHA-256, with a readable prefix beside it.** The prefix is what a
customer sees in a list and what the lookup uses; the secret is checked by hash
and constant-time comparison. The same reasoning as invitations — the platform
only ever needs to *check* a key, never to read one back — with the addition that
a prefix makes a leaked key identifiable in a log without the log holding the
key.
**A key never outranks its owner.** Its scopes are a subset of what the issuing
user could do, and that is re-checked on every request rather than frozen at
issue time. So deactivating somebody disables their keys in the same moment,
which is the case that otherwise goes wrong quietly: a leaver's integration
outliving their account.
Revision ID: a7d9f1c3e80b
Revises: f5c7e9b1d70a
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision: str = "a7d9f1c3e80b"
down_revision: Union[str, Sequence[str], None] = "f5c7e9b1d70a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"api_keys",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("prefix", sa.String(16), nullable=False),
sa.Column("key_hash", sa.String(64), nullable=False),
sa.Column("scopes", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.UniqueConstraint("prefix", name="uq_api_keys_prefix"),
)
op.create_index("ix_api_keys_tenant", "api_keys", ["tenant_id"])
op.create_index("ix_api_keys_user", "api_keys", ["user_id"])
op.create_index(
"ix_api_keys_live", "api_keys", ["prefix"],
postgresql_where=sa.text("revoked_at IS NULL"),
)
op.execute("ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE api_keys FORCE ROW LEVEL SECURITY")
op.execute(
"""
CREATE POLICY tenant_isolation ON api_keys
USING (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
WITH CHECK (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
"""
)
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS tenant_isolation ON api_keys")
op.drop_index("ix_api_keys_live", table_name="api_keys")
op.drop_index("ix_api_keys_user", table_name="api_keys")
op.drop_index("ix_api_keys_tenant", table_name="api_keys")
op.drop_table("api_keys")