Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions app/api/routes_organization_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from app.db.models import OrganizationMembership, User
from app.db.session import get_db
from app.rbac import require_org_permission_or_platform_admin
from app.schemas.organization_config import ProviderKeyIn, ProviderKeyOut
from app.services import organization_config_service

# BYOK provider-key storage (design audit gap #4). Gated on manage_org --
# an org-wide, billing-adjacent infrastructure setting, the same
# administrative character as manage_billing/manage_teams, not a
# literature-API self-service action, so it is deliberately NOT one of
# the public literature:read/usage:read scopes a self-service key can
# be issued with (see routes_apikeys.py's PUBLIC_SCOPE_TO_PERMISSION).
router = APIRouter(prefix="/orgs/{org_id}/provider-keys", tags=["provider-keys"])

MANAGE_ORG = "manage_org"


def _to_out(db: Session, config) -> ProviderKeyOut:
updated_by_email = None
if config and config.updated_by_user_id:
user = db.query(User).filter(User.id == config.updated_by_user_id).first()
updated_by_email = user.email if user else None

return ProviderKeyOut(
provider=config.llm_provider if config else None,
has_key=bool(config and config.llm_api_key_encrypted),
updated_at=config.updated_at.isoformat() if config and config.updated_at else None,
updated_by_email=updated_by_email,
)


@router.get("", response_model=ProviderKeyOut)
def get_provider_key(
org_id: int,
db: Session = Depends(get_db),
membership: OrganizationMembership = Depends(require_org_permission_or_platform_admin(MANAGE_ORG)),
):
return _to_out(db, organization_config_service.get_organization_config(db, org_id))


@router.put("/{provider}", response_model=ProviderKeyOut)
def put_provider_key(
org_id: int,
provider: str,
body: ProviderKeyIn,
db: Session = Depends(get_db),
membership: OrganizationMembership = Depends(require_org_permission_or_platform_admin(MANAGE_ORG)),
):
try:
config = organization_config_service.set_provider_key(
db, org_id, provider, body.api_key, updated_by_user_id=membership.user_id,
)
except ValueError as e:
raise HTTPException(400, str(e))
except RuntimeError as e:
# crypto.encrypt() raises when CONFIG_ENCRYPTION_KEY isn't set --
# a loud, deliberate 500, never a silently-dropped or plaintext-
# stored credential (same contract routes_config.py's own
# update_config endpoint follows for the platform-wide key).
raise HTTPException(500, str(e))
return _to_out(db, config)


@router.delete("/{provider}", response_model=ProviderKeyOut)
def delete_provider_key(
org_id: int,
provider: str,
db: Session = Depends(get_db),
membership: OrganizationMembership = Depends(require_org_permission_or_platform_admin(MANAGE_ORG)),
):
try:
config = organization_config_service.clear_provider_key(
db, org_id, provider, updated_by_user_id=membership.user_id,
)
except ValueError as e:
raise HTTPException(400, str(e))
if config is None:
raise HTTPException(404, f"No {provider} key is configured for this organization.")
return _to_out(db, config)
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from app.api.routes_apikeys import router as apikeys_router
from app.api.routes_apikeys import exchange_router as apikeys_exchange_router
from app.api.routes_apikeys import me_router as apikeys_me_router
from app.api.routes_organization_config import router as provider_keys_router
from app.api.routes_oauth_clients import router as oauth_clients_router
from app.api.routes_oauth_token import router as oauth_token_router
from app.api.routes_org_sso import router as org_sso_router
Expand Down Expand Up @@ -104,6 +105,7 @@
app.include_router(apikeys_router)
app.include_router(apikeys_exchange_router)
app.include_router(apikeys_me_router)
app.include_router(provider_keys_router)
app.include_router(oauth_clients_router)
app.include_router(oauth_token_router)
app.include_router(org_sso_router)
Expand Down
15 changes: 15 additions & 0 deletions app/schemas/organization_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from pydantic import BaseModel


class ProviderKeyIn(BaseModel):
api_key: str


class ProviderKeyOut(BaseModel):
"""No credential field, ever, regardless of caller role -- has_key
only, the same write-only convention app/schemas/config.py's
GlobalConfigOut already established for the platform-wide LLM key."""
provider: str | None
has_key: bool
updated_at: str | None
updated_by_email: str | None
5 changes: 5 additions & 0 deletions app/services/audit_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ class AuditEventType:
# status, and the key material itself are unaffected and keep their own
# existing event types.
API_KEY_RENAMED = "api_key_renamed"
# M14 (BYOK provider-key storage, design audit gap #4): an
# organization's own Claude/OpenAI key. Never the key itself in
# metadata -- same rule API_KEY_CREATED's own entry follows.
PROVIDER_KEY_SET = "provider_key_set"
PROVIDER_KEY_CLEARED = "provider_key_cleared"
OAUTH_CLIENT_CREATED = "oauth_client_created"
OAUTH_CLIENT_REVOKED = "oauth_client_revoked"
SSO_CONFIGURATION_CREATED = "sso_configuration_created"
Expand Down
113 changes: 113 additions & 0 deletions app/services/organization_config_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""M14 (design audit gap #4's "organisation-scoped BYOK provider-key
service"): an organization can store its own Claude/OpenAI key, used
instead of the platform's own when the public API answers that
organization's questions. The underlying organization_config table has
existed since the multi-tenant schema migration with nobody reading or
writing it -- this module is its first real consumer.

Scoped deliberately narrowly, matching how far "storage" goes on its
own: this only stores and retrieves the encrypted key. Actually routing
a /v1/literature/answers call through the stored provider (Claude/
OpenAI client calls, token accounting, llm.tokens.* usage events) is
the rest of gap #4 and is not built here -- storing a key an org can
never yet be routed through is a real, honestly-scoped gap, not a
hidden one (the same "mechanism before the business/product decision
that uses it" shape this project's M3/M6/M7 all already established,
here applied to an engineering dependency rather than a pricing one).

Also not yet exposed through the public gateway's own /v1/provider-keys/
{provider} path the design doc names -- only through this org-admin
endpoint (PUT/GET/DELETE /orgs/{org_id}/provider-keys), the same one
Studio's own LLM settings page would call. Routing this through the
gateway for direct external-developer use is follow-up work.
"""
from datetime import datetime

from sqlalchemy.orm import Session

from app.core import crypto
from app.db.models import OrganizationConfig
from app.services import audit_service
from app.services.audit_service import AuditEventType

# One provider/key pair per organization at a time -- the same shape
# omnibioai-auth's own GlobalConfig already uses for the platform-wide
# LLM credential (config_service.py), not a new multi-provider-per-org
# design. Setting a key for a different provider replaces whichever one
# was previously configured, exactly like PUT /auth/config with a new
# llm_provider does there.
SUPPORTED_PROVIDERS = {"claude", "openai"}


def get_organization_config(db: Session, organization_id: int) -> OrganizationConfig | None:
return db.query(OrganizationConfig).filter_by(organization_id=organization_id).first()


def _get_or_create(db: Session, organization_id: int) -> OrganizationConfig:
config = get_organization_config(db, organization_id)
if config is None:
config = OrganizationConfig(organization_id=organization_id)
db.add(config)
db.flush()
return config


def set_provider_key(
db: Session, organization_id: int, provider: str, api_key: str, updated_by_user_id: int,
) -> OrganizationConfig:
"""Raises ValueError for an unsupported provider name, and (via
crypto.encrypt) RuntimeError if CONFIG_ENCRYPTION_KEY isn't
configured -- never silently stores a credential in plaintext."""
if provider not in SUPPORTED_PROVIDERS:
raise ValueError(f"Unsupported provider {provider!r}; supported: {sorted(SUPPORTED_PROVIDERS)}")

config = _get_or_create(db, organization_id)
config.llm_provider = provider
config.llm_api_key_encrypted = crypto.encrypt(api_key)
config.updated_at = datetime.utcnow()
config.updated_by_user_id = updated_by_user_id
db.flush()
# Never the key itself or its ciphertext in audit metadata -- the
# same "name/scope only" rule apikey_service.create_api_key's own
# audit entry follows.
audit_service.log_event(
db, AuditEventType.PROVIDER_KEY_SET, actor_user_id=updated_by_user_id,
organization_id=organization_id, resource_type="organization_config", resource_id=config.id,
after_state={"provider": provider}, metadata={"provider": provider},
commit=False,
)
db.commit()
db.refresh(config)
return config


def clear_provider_key(
db: Session, organization_id: int, provider: str, updated_by_user_id: int,
) -> OrganizationConfig | None:
"""Returns None (the route turns this into 404) if the organization
has no config row at all, or its currently-configured provider
doesn't match `provider` -- there is only one slot, so "clear
openai's key" when the org actually has a claude key configured
isn't a no-op clear of the wrong thing, it's simply nothing to do.
"""
if provider not in SUPPORTED_PROVIDERS:
raise ValueError(f"Unsupported provider {provider!r}; supported: {sorted(SUPPORTED_PROVIDERS)}")

config = get_organization_config(db, organization_id)
if config is None or config.llm_provider != provider:
return None

config.llm_provider = None
config.llm_api_key_encrypted = None
config.updated_at = datetime.utcnow()
config.updated_by_user_id = updated_by_user_id
db.flush()
audit_service.log_event(
db, AuditEventType.PROVIDER_KEY_CLEARED, actor_user_id=updated_by_user_id,
organization_id=organization_id, resource_type="organization_config", resource_id=config.id,
before_state={"provider": provider}, metadata={"provider": provider},
commit=False,
)
db.commit()
db.refresh(config)
return config
Loading
Loading