Skip to content
Closed
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
> a sandboxed agent runtime, hybrid knowledge retrieval, and a native project
> board, all on one Postgres-backed platform you run yourself.

<!-- Badge placeholders — replace <org>/forge with your repository slug before publishing. -->
<!-- Badge placeholders — replace service-hive/forge with your repository slug before publishing. -->
[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE)
[![CI](https://img.shields.io/badge/CI-see%20Actions-lightgrey.svg)](../../actions)
[![Status: pre-1.0](https://img.shields.io/badge/status-pre--1.0%20(active)-orange.svg)](#status)
Expand Down Expand Up @@ -65,7 +65,7 @@ for the honest per-feature ledger.
Requires Docker Engine 24+ and the Docker Compose v2 plugin, plus `make`.

```bash
git clone https://github.com/<org>/forge.git
git clone https://github.com/service-hive/forge.git
cd forge
cp .env.example .env # then set SECRET_KEY, POSTGRES_PASSWORD, DOMAIN, ...
make dev # build + start the full stack, migrate, seed, wait healthy
Expand Down
216 changes: 216 additions & 0 deletions apps/api/forge_api/auth/apikeys_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""Postgres-backed platform API-key backend (Phase-2 persistence).

:class:`DbAPIKeyBackend` is a drop-in, durable alternative to
:class:`~forge_api.auth.apikeys.InMemoryAPIKeyBackend` that satisfies the **same**
:class:`~forge_api.auth.apikeys.APIKeyBackend` seam (``add`` / ``by_prefix`` /
``list`` / ``get``) the :class:`~forge_api.auth.apikeys.APIKeyStore` mints, verifies,
lists, and revokes through. The composition root swaps it in behind
``FORGE_APIKEY_BACKEND=db``; the default stays ``memory`` and the in-memory store
remains the unit-test default, so no existing behaviour changes.

It maps the domain :class:`~forge_api.auth.apikeys.APIKeyRecord` onto the canonical
``platform_api_key`` ORM row (``PlatformAPIKey``, created by migration 0020, F37) —
so **no new migration** is required. Two storage-boundary details are load-bearing:

* **Enum taxonomy.** The record carries an
:class:`~forge_contracts.enums.APIKeyKind` (BYOK-flavoured; the store only ever
mints ``SYSTEM`` for platform auth), while the frozen ``platform_api_key.kind``
column is a :class:`~forge_contracts.auth.PlatformKeyKind`. The two are bridged
by :data:`_KIND_TO_PLATFORM` / :data:`_PLATFORM_TO_KIND`, a documented mapping
that round-trips the platform-auth kinds (``SYSTEM`` ⇄ ``service``,
``MODEL_PROVIDER`` ⇄ ``personal``) verbatim and never emits ``agent_runner`` on
write (so the ``agent_runner ⇒ expires_at`` CHECK is never tripped). The two
BYOK-only kinds that never reach the platform-key store fold onto those two
slots and read back as their canonical twin (documented, unreachable in practice).

* **Mutation through returned records.** ``APIKeyStore.revoke`` /
``revoke_for_user`` flip ``is_active`` and ``verify`` stamps ``last_used_at`` by
mutating the record object the backend returns — the in-memory store persists
that only because it hands back live references. To preserve that behaviour
exactly, this backend returns :class:`_LiveAPIKeyRecord` instances that
write-through those two fields (``is_active`` → ``revoked_at``; ``last_used_at``)
to the row on assignment. Revocation and last-used tracking therefore work
identically on both backends.
"""

from __future__ import annotations

import builtins
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

from pydantic import PrivateAttr
from sqlalchemy import select, update

from forge_api.auth.apikeys import APIKeyRecord
from forge_contracts.auth import PlatformKeyKind
from forge_contracts.enums import APIKeyKind, UserRole
from forge_db.models import PlatformAPIKey
from forge_db.models.enums import UserRole as DbUserRole

if TYPE_CHECKING:
from sqlalchemy.orm import Session, sessionmaker

__all__ = ["DbAPIKeyBackend"]

#: Forward map: the record's :class:`APIKeyKind` → the column's
#: :class:`PlatformKeyKind`. ``agent_runner`` is deliberately never a target so the
#: ``agent_runner ⇒ expires_at`` CHECK is never at risk; the platform-auth kinds
#: (``SYSTEM``/``MODEL_PROVIDER``) map onto distinct slots and round-trip verbatim.
_KIND_TO_PLATFORM: dict[APIKeyKind, PlatformKeyKind] = {
APIKeyKind.SYSTEM: PlatformKeyKind.SERVICE,
APIKeyKind.MODEL_PROVIDER: PlatformKeyKind.PERSONAL,
APIKeyKind.INTEGRATION_TOKEN: PlatformKeyKind.SERVICE,
APIKeyKind.MCP_TOKEN: PlatformKeyKind.PERSONAL,
}

#: Reverse map for reads. Total over :class:`PlatformKeyKind`; ``agent_runner`` is
#: covered defensively (this backend never writes it) and reads back as ``SYSTEM``.
_PLATFORM_TO_KIND: dict[PlatformKeyKind, APIKeyKind] = {
PlatformKeyKind.SERVICE: APIKeyKind.SYSTEM,
PlatformKeyKind.PERSONAL: APIKeyKind.MODEL_PROVIDER,
PlatformKeyKind.AGENT_RUNNER: APIKeyKind.SYSTEM,
}

#: Record fields whose in-place mutation the store relies on the backend to
#: persist (the in-memory store gets this free via shared references).
_WRITE_THROUGH_FIELDS = frozenset({"is_active", "last_used_at"})


def _aware(value: datetime | None) -> datetime | None:
"""Normalise a stored timestamp to timezone-aware UTC (defensive)."""
if value is None:
return None
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)


class _LiveAPIKeyRecord(APIKeyRecord):
"""An :class:`APIKeyRecord` that write-throughs revoke/last-used mutations.

``APIKeyStore`` mutates ``is_active`` / ``last_used_at`` on the record objects
the backend returns; the in-memory store persists those only because it hands
back the very objects it stores. This subclass reproduces that behaviour for
the DB backend by forwarding those two assignments to a persistence hook, so a
``revoke`` / ``revoke_for_user`` / ``verify`` behaves identically on both.
"""

_persist: Callable[[uuid.UUID, str, Any], None] | None = PrivateAttr(default=None)

def __setattr__(self, name: str, value: Any) -> None:
super().__setattr__(name, value)
private = getattr(self, "__pydantic_private__", None)
hook = private.get("_persist") if private else None
if hook is not None and name in _WRITE_THROUGH_FIELDS:
hook(self.id, name, value)


class DbAPIKeyBackend:
"""A Postgres-backed API-key backend (implements ``APIKeyBackend``)."""

def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._sf = session_factory

# ------------------------------------------------------------------ #
# Mapping #
# ------------------------------------------------------------------ #

def _to_row(self, record: APIKeyRecord) -> PlatformAPIKey:
"""Build the ORM row for a domain record (kind/role/active translated)."""
return PlatformAPIKey(
id=record.id,
workspace_id=record.workspace_id,
name=record.name,
# Non-secret unique lookup id the column requires (the record has no
# ``key_id`` of its own); derived from the unique record id, stable
# across re-adds and never surfaced back into the domain.
key_id=record.id.hex[:16],
key_hash=record.token_hash,
key_prefix=record.key_prefix,
kind=_KIND_TO_PLATFORM[record.kind],
role=DbUserRole(record.role.value),
created_by=record.user_id,
created_at=record.created_at,
last_used_at=record.last_used_at,
expires_at=record.expires_at,
revoked_at=None if record.is_active else datetime.now(UTC),
)

def _to_record(self, row: PlatformAPIKey) -> _LiveAPIKeyRecord:
"""Rebuild a live (write-through) domain record from a persisted row."""
record = _LiveAPIKeyRecord(
id=row.id,
workspace_id=row.workspace_id,
name=row.name,
kind=_PLATFORM_TO_KIND[row.kind],
role=UserRole(row.role.value),
key_prefix=row.key_prefix,
token_hash=row.key_hash,
user_id=row.created_by,
created_at=_aware(row.created_at), # type: ignore[arg-type]
last_used_at=_aware(row.last_used_at),
expires_at=_aware(row.expires_at),
is_active=row.revoked_at is None,
)
record._persist = self._persist_field
return record

# ------------------------------------------------------------------ #
# Write-through persistence for in-place record mutation #
# ------------------------------------------------------------------ #

def _persist_field(self, key_id: uuid.UUID, name: str, value: Any) -> None:
"""Persist a mutation of ``is_active`` / ``last_used_at`` on one row."""
if name == "last_used_at":
values: dict[str, Any] = {"last_used_at": value}
else: # is_active → revoked_at (kept for audit; None re-activates)
values = {"revoked_at": None if value else datetime.now(UTC)}
with self._sf() as session:
session.execute(
update(PlatformAPIKey).where(PlatformAPIKey.id == key_id).values(**values)
)
session.commit()

# ------------------------------------------------------------------ #
# APIKeyBackend seam #
# ------------------------------------------------------------------ #

def add(self, record: APIKeyRecord) -> None:
"""Persist a record; overwrites on a repeated id (mirrors the dict store)."""
with self._sf() as session:
session.merge(self._to_row(record))
session.commit()

def by_prefix(self, prefix: str) -> builtins.list[APIKeyRecord]:
"""Every record whose display prefix matches, oldest first (stable)."""
with self._sf() as session:
rows = session.scalars(
select(PlatformAPIKey)
.where(PlatformAPIKey.key_prefix == prefix)
.order_by(PlatformAPIKey.created_at.asc(), PlatformAPIKey.id.asc())
).all()
return [self._to_record(r) for r in rows]

def list(self, workspace_id: uuid.UUID) -> builtins.list[APIKeyRecord]:
"""Every record in a workspace, oldest first (stable ordering)."""
with self._sf() as session:
rows = session.scalars(
select(PlatformAPIKey)
.where(PlatformAPIKey.workspace_id == workspace_id)
.order_by(PlatformAPIKey.created_at.asc(), PlatformAPIKey.id.asc())
).all()
return [self._to_record(r) for r in rows]

def get(
self, workspace_id: uuid.UUID, key_id: uuid.UUID
) -> APIKeyRecord | None:
"""The record with ``key_id`` in ``workspace_id``, else ``None``."""
with self._sf() as session:
row = session.scalars(
select(PlatformAPIKey).where(
PlatformAPIKey.id == key_id,
PlatformAPIKey.workspace_id == workspace_id,
)
).first()
return self._to_record(row) if row is not None else None
54 changes: 49 additions & 5 deletions apps/api/forge_api/auth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@

from fastapi import Depends, Header, HTTPException, status

from forge_api.auth.apikeys import APIKeyInfo, APIKeyStore
from forge_api.auth.apikeys import APIKeyBackend, APIKeyInfo, APIKeyStore
from forge_api.auth.crypto import EnvelopeCipher, default_cipher
from forge_api.auth.keyring import KeyRing
from forge_api.auth.models import OAuthChallenge, OAuthResult
from forge_api.auth.oauth import OAuthClient, UnsupportedOAuthProviderError
from forge_api.auth.providers import get_default_provider, resolve_secret
from forge_api.auth.rbac import Permission, PermissionDeniedError, ensure
from forge_api.auth.vault import SecretVault
from forge_api.auth.vault import SecretStore, SecretVault
from forge_api.deps import Principal
from forge_api.observability.redaction import redact_text
from forge_api.services.auth_audit import AuthAuditEmitter
Expand Down Expand Up @@ -168,6 +168,26 @@ def _resolve_master_key(secret_key: bytes | None) -> bytes:
)


def _build_apikey_backend() -> APIKeyBackend | None:
"""Return the API-key backend selected by ``FORGE_APIKEY_BACKEND``.

``memory`` (default) → ``None``, so :class:`APIKeyStore` falls back to the
hermetic :class:`~forge_api.auth.apikeys.InMemoryAPIKeyBackend` (unit-test
default, no Postgres); ``db`` → the durable
:class:`~forge_api.auth.apikeys_db.DbAPIKeyBackend` bound to the shared
session factory. Both satisfy the same ``APIKeyBackend`` seam, so the swap is
behaviour-preserving (mint / verify / list / revoke).
"""
from forge_api.settings import get_settings

if get_settings().apikey_backend == "db":
from forge_api.auth.apikeys_db import DbAPIKeyBackend
from forge_api.db import get_session_factory

return DbAPIKeyBackend(get_session_factory())
return None


def _keyring_for_master(master: bytes) -> KeyRing:
"""Build a :class:`KeyRing` whose current KEK is the resolved ``master`` key.

Expand All @@ -188,14 +208,35 @@ def _keyring_for_master(master: bytes) -> KeyRing:
return KeyRing(keys, current_version)


def _build_secret_store() -> SecretStore | None:
"""Return the secret-vault store selected by ``FORGE_SECRET_BACKEND``.

``memory`` (default) → ``None``, so :class:`SecretVault` falls back to the
hermetic :class:`~forge_api.auth.vault.InMemorySecretStore` (unit-test default,
no Postgres); ``db`` → the durable
:class:`~forge_api.auth.vault_db.DbSecretStore` bound to the shared session
factory. Both satisfy the same ``SecretStore`` seam, so the swap is
behaviour-preserving (add / get / list / remove + rotation's ``all_records``).
"""
from forge_api.settings import get_settings

if get_settings().secret_backend == "db":
from forge_api.auth.vault_db import DbSecretStore
from forge_api.db import get_session_factory

return DbSecretStore(get_session_factory())
return None


def _build_vault(master: bytes) -> SecretVault:
"""Construct the vault, selecting envelope vs single-tier cipher via config."""
cipher_subkey = _subkey(master, b"forge-cipher")
store = _build_secret_store()
if _envelope_enabled():
keyring = _keyring_for_master(master)
cipher = EnvelopeCipher(keyring, legacy=default_cipher(cipher_subkey))
return SecretVault(cipher=cipher)
return SecretVault(cipher=default_cipher(cipher_subkey))
return SecretVault(cipher=cipher, store=store)
return SecretVault(cipher=default_cipher(cipher_subkey), store=store)


class AuthService:
Expand All @@ -212,7 +253,10 @@ def __init__(
audit_sink: AuditSink | None = None,
) -> None:
master = _resolve_master_key(secret_key)
self.api_keys = api_keys or APIKeyStore(secret_key=_subkey(master, b"forge-apikey"))
self.api_keys = api_keys or APIKeyStore(
secret_key=_subkey(master, b"forge-apikey"),
backend=_build_apikey_backend(),
)
self.vault = vault or _build_vault(master)
# Constructs without network access; the IdP is only contacted when an
# authorization-code exchange is actually requested.
Expand Down
Loading
Loading