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
92 changes: 85 additions & 7 deletions app/api/routes_apikeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from app.db.models import ApiKey, OrganizationMembership
from app.db.session import get_db
from app.rbac import get_current_user, require_org_permission_or_platform_admin
from app.schemas.apikeys import ApiKeyCreate, ApiKeyCreated, ApiKeyExchangeIn, ApiKeyExchangeOut, ApiKeyOut
from app.schemas.apikeys import ApiKeyCreate, ApiKeyCreated, ApiKeyExchangeIn, ApiKeyExchangeOut, ApiKeyOut, ApiKeyRename
from app.services import apikey_service, org_service

router = APIRouter(prefix="/orgs/{org_id}/api-keys", tags=["api-keys"])
Expand Down Expand Up @@ -42,7 +42,8 @@ def create_api_key(
caller_permissions = org_service.permissions_for_membership(membership)
try:
api_key, full_key = apikey_service.create_api_key(
db, org_id, membership.user_id, body.name, body.scopes, caller_permissions
db, org_id, membership.user_id, body.name, body.scopes, caller_permissions,
expires_at=body.expires_at,
)
except ValueError as e:
raise HTTPException(400, str(e))
Expand All @@ -51,6 +52,7 @@ def create_api_key(
name=api_key.name,
key_prefix=api_key.key_prefix,
scopes=api_key.scopes or [],
expires_at=api_key.expires_at,
key=full_key,
)

Expand All @@ -64,6 +66,24 @@ def list_api_keys(
return [_key_out(k) for k in apikey_service.list_api_keys(db, org_id)]


@router.patch("/{key_id}", response_model=ApiKeyOut)
def rename_api_key(
org_id: int,
key_id: int,
body: ApiKeyRename,
db: Session = Depends(get_db),
membership: OrganizationMembership = Depends(require_org_permission_or_platform_admin(MANAGE_API_KEYS)),
):
key = apikey_service.get_api_key(db, org_id, key_id)
if not key:
raise HTTPException(404, "API key not found")
try:
apikey_service.rename_api_key(db, key, body.name, actor_user_id=membership.user_id)
except ValueError as e:
raise HTTPException(400, str(e))
return _key_out(key)


@router.delete("/{key_id}", status_code=204)
def revoke_api_key(
org_id: int,
Expand Down Expand Up @@ -120,9 +140,31 @@ def exchange_api_key(
# Org admins keep the org-wide /orgs/{org_id}/api-keys routes above.
# ---------------------------------------------------------------------------

DEFAULT_SELF_SERVICE_SCOPES = ["dataset.read"]
MAX_ACTIVE_KEYS_PER_USER = 10

# M9 (API-key lifecycle, design doc's "Scopes" section): the public,
# developer-facing scope vocabulary self-service keys are issued and
# displayed in -- distinct from the internal dot-format IAM permission
# names (app/core/permission_names.py's registry) that org roles are
# actually granted and that create_api_key/exchange_api_key check
# against. Translating at this HTTP boundary only, rather than renaming
# dataset.read/usage.read themselves, keeps both of those exactly as they
# are everywhere else they're used -- role grants, the org-admin
# /orgs/{org_id}/api-keys router below, and every downstream consumer of
# an exchanged token's `permissions` claim (the gateway and policy engine
# still see "dataset.read"/"usage.read", unchanged).
PUBLIC_SCOPE_TO_PERMISSION = {
"literature:read": "dataset.read",
"usage:read": "usage.read",
}
PERMISSION_TO_PUBLIC_SCOPE = {v: k for k, v in PUBLIC_SCOPE_TO_PERMISSION.items()}

DEFAULT_SELF_SERVICE_SCOPES = ["literature:read"]


def _to_public_scopes(internal_scopes: list[str]) -> list[str]:
return [PERMISSION_TO_PUBLIC_SCOPE.get(s, s) for s in internal_scopes]


def _self_service_membership(
user: dict = Depends(get_current_user),
Expand Down Expand Up @@ -158,12 +200,25 @@ def _own_keys(db: Session, membership: OrganizationMembership):
)


def _me_key_out(key: ApiKey) -> ApiKeyOut:
return ApiKeyOut(
id=key.id,
name=key.name,
key_prefix=key.key_prefix,
scopes=_to_public_scopes(key.scopes or []),
status=key.status,
created_at=key.created_at,
expires_at=key.expires_at,
last_used_at=key.last_used_at,
)


@me_router.get("", response_model=list[ApiKeyOut])
def list_my_api_keys(
db: Session = Depends(get_db),
membership: OrganizationMembership = Depends(_self_service_membership),
):
return [_key_out(k) for k in _own_keys(db, membership).all()]
return [_me_key_out(k) for k in _own_keys(db, membership).all()]


@me_router.post("", response_model=ApiKeyCreated, status_code=201)
Expand All @@ -175,23 +230,46 @@ def create_my_api_key(
active = _own_keys(db, membership).filter(ApiKey.status == "active").count()
if active >= MAX_ACTIVE_KEYS_PER_USER:
raise HTTPException(409, f"At most {MAX_ACTIVE_KEYS_PER_USER} active keys; revoke one first")
scopes = body.scopes or DEFAULT_SELF_SERVICE_SCOPES
public_scopes = body.scopes or DEFAULT_SELF_SERVICE_SCOPES
unknown_scopes = set(public_scopes) - set(PUBLIC_SCOPE_TO_PERMISSION)
if unknown_scopes:
raise HTTPException(400, f"Unknown scope(s): {sorted(unknown_scopes)}")
internal_scopes = [PUBLIC_SCOPE_TO_PERMISSION[s] for s in public_scopes]
try:
api_key, full_key = apikey_service.create_api_key(
db, membership.organization_id, membership.user_id, body.name, scopes,
db, membership.organization_id, membership.user_id, body.name, internal_scopes,
org_service.permissions_for_membership(membership),
expires_at=body.expires_at,
)
except ValueError as e:
raise HTTPException(400, str(e))
return ApiKeyCreated(
id=api_key.id,
name=api_key.name,
key_prefix=api_key.key_prefix,
scopes=api_key.scopes or [],
scopes=_to_public_scopes(api_key.scopes or []),
expires_at=api_key.expires_at,
key=full_key,
)


@me_router.patch("/{key_id}", response_model=ApiKeyOut)
def rename_my_api_key(
key_id: int,
body: ApiKeyRename,
db: Session = Depends(get_db),
membership: OrganizationMembership = Depends(_self_service_membership),
):
key = _own_keys(db, membership).filter(ApiKey.id == key_id).first()
if not key:
raise HTTPException(404, "API key not found")
try:
apikey_service.rename_api_key(db, key, body.name, actor_user_id=membership.user_id)
except ValueError as e:
raise HTTPException(400, str(e))
return _me_key_out(key)


@me_router.delete("/{key_id}", status_code=204)
def revoke_my_api_key(
key_id: int,
Expand Down
6 changes: 6 additions & 0 deletions app/schemas/apikeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@
class ApiKeyCreate(BaseModel):
name: str
scopes: list[str] = []
expires_at: datetime | None = None # M9: optional, self-service-settable; None = no expiry


class ApiKeyRename(BaseModel):
name: str


class ApiKeyCreated(BaseModel):
id: int
name: str | None
key_prefix: str
scopes: list[str]
expires_at: datetime | None = None
key: str # full plaintext key -- returned exactly once, at creation


Expand Down
44 changes: 43 additions & 1 deletion app/services/apikey_service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import hashlib
import secrets
import uuid
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone

from sqlalchemy.orm import Session

Expand All @@ -24,13 +24,27 @@ def _hash_key(full_key: str) -> str:
return hashlib.sha256(full_key.encode()).hexdigest()


def _normalize_expiry(expires_at: datetime | None) -> datetime | None:
"""Every other timestamp on this model (created_at, last_used_at,
revoked_at) is a naive UTC datetime.utcnow(), and verify_api_key's own
expiry check compares against one -- so a caller-supplied, possibly
tz-aware expires_at is converted to the same naive-UTC shape here,
once, rather than every comparison site needing to handle both."""
if expires_at is None:
return None
if expires_at.tzinfo is not None:
expires_at = expires_at.astimezone(timezone.utc).replace(tzinfo=None)
return expires_at


def create_api_key(
db: Session,
organization_id: int,
creator_user_id: int,
name: str,
scopes: list[str],
caller_permissions: set[str],
expires_at: datetime | None = None,
) -> tuple[ApiKey, str]:
"""Returns (ApiKey row, full plaintext key). The plaintext is never
persisted -- only its sha256 hash is stored -- so this is the only
Expand All @@ -45,6 +59,10 @@ def create_api_key(
if invalid_scopes:
raise ValueError(f"Cannot grant scopes you don't hold: {sorted(invalid_scopes)}")

expires_at = _normalize_expiry(expires_at)
if expires_at is not None and expires_at <= datetime.utcnow():
raise ValueError("expires_at must be in the future")

full_key = _generate_key()
api_key = ApiKey(
organization_id=organization_id,
Expand All @@ -55,6 +73,7 @@ def create_api_key(
scopes=scopes,
status="active",
created_at=datetime.utcnow(),
expires_at=expires_at,
)
db.add(api_key)
db.flush()
Expand Down Expand Up @@ -107,6 +126,29 @@ def revoke_api_key(
return api_key


def rename_api_key(
db: Session, api_key: ApiKey, new_name: str, actor_user_id: int | None = None,
) -> ApiKey:
"""Change a key's display name only -- scopes, status, and the key
material itself are untouched, so this never needs the
caller_permissions re-check create_api_key does."""
if not new_name or not new_name.strip():
raise ValueError("name must not be empty")
old_name = api_key.name
api_key.name = new_name
db.flush()
db.refresh(api_key)
audit_service.log_event(
db, AuditEventType.API_KEY_RENAMED, actor_user_id=actor_user_id,
organization_id=api_key.organization_id, resource_type="api_key", resource_id=api_key.id,
before_state={"name": old_name}, after_state={"name": api_key.name},
metadata={"old_name": old_name, "new_name": api_key.name},
commit=False,
)
db.commit()
return api_key


def verify_api_key(db: Session, full_key: str) -> ApiKey | None:
"""Look up an active, unexpired key by the hash of its full value.
Used by exchange_api_key() below (POST /auth/api-keys/exchange)."""
Expand Down
4 changes: 4 additions & 0 deletions app/services/audit_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class AuditEventType:
USER_DISABLED = "user_disabled"
API_KEY_CREATED = "api_key_created"
API_KEY_REVOKED = "api_key_revoked"
# M9 (API-key lifecycle): renaming a key's display name only -- scopes,
# status, and the key material itself are unaffected and keep their own
# existing event types.
API_KEY_RENAMED = "api_key_renamed"
OAUTH_CLIENT_CREATED = "oauth_client_created"
OAUTH_CLIENT_REVOKED = "oauth_client_revoked"
SSO_CONFIGURATION_CREATED = "sso_configuration_created"
Expand Down
64 changes: 64 additions & 0 deletions tests/test_apikeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Developer: Manish Kumar <manish@omnibioai.org>
"""
import uuid
from datetime import datetime, timedelta

import pytest
from sqlalchemy import create_engine
Expand Down Expand Up @@ -116,6 +117,69 @@ def test_revoke_api_key(client, org):
assert revoked["status"] == "revoked"


def test_create_api_key_with_future_expires_at(client, org):
"""A caller-supplied expires_at in the future is stored and returned as-is."""
future = (datetime.utcnow() + timedelta(days=30)).isoformat()
resp = client.post(
f"/orgs/{org['id']}/api-keys",
json={"name": "Expiring", "scopes": [], "expires_at": future},
headers=org["owner_headers"],
)
assert resp.status_code == 201
assert resp.json()["expires_at"] is not None


def test_create_api_key_rejects_past_expires_at(client, org):
"""expires_at in the past is rejected -- a key that's already expired the moment it's created
is never a legitimate request.
"""
past = (datetime.utcnow() - timedelta(days=1)).isoformat()
resp = client.post(
f"/orgs/{org['id']}/api-keys",
json={"name": "Already expired", "scopes": [], "expires_at": past},
headers=org["owner_headers"],
)
assert resp.status_code == 400


def test_rename_api_key(client, org):
"""PATCH renames a key and the new name is reflected in a subsequent listing."""
create = client.post(
f"/orgs/{org['id']}/api-keys", json={"name": "Old name", "scopes": []}, headers=org["owner_headers"]
)
key_id = create.json()["id"]

resp = client.patch(
f"/orgs/{org['id']}/api-keys/{key_id}", json={"name": "New name"}, headers=org["owner_headers"]
)
assert resp.status_code == 200
assert resp.json()["name"] == "New name"

listed = client.get(f"/orgs/{org['id']}/api-keys", headers=org["owner_headers"])
assert next(k for k in listed.json() if k["id"] == key_id)["name"] == "New name"


def test_rename_api_key_rejects_empty_name(client, org):
"""An empty/whitespace-only name is rejected rather than silently blanking the key's name."""
create = client.post(
f"/orgs/{org['id']}/api-keys", json={"name": "Keep me", "scopes": []}, headers=org["owner_headers"]
)
key_id = create.json()["id"]

resp = client.patch(
f"/orgs/{org['id']}/api-keys/{key_id}", json={"name": " "}, headers=org["owner_headers"]
)
assert resp.status_code == 400


def test_rename_nonexistent_key_404(client, org):
"""Renaming a key id that doesn't exist in this org returns 404."""
resp = client.patch(
f"/orgs/{org['id']}/api-keys/999999", json={"name": "Nope"}, headers=org["owner_headers"]
)
assert resp.status_code == 404


def test_missing_token_rejected(client, org):
"""Listing an organization's API keys without a bearer token is rejected with 401 or 403."""
resp = client.get(f"/orgs/{org['id']}/api-keys")
Expand Down
Loading
Loading