From 1a9eb680fb7796581b1986a89dcdd2d39c505c72 Mon Sep 17 00:00:00 2001 From: Edwin <9cb14c1ec0@sendmeemail.xyz> Date: Thu, 19 Feb 2026 10:48:47 -0500 Subject: [PATCH 1/3] support multiple API keys per team Move api_key_hash/api_key_prefix from teams table into a new api_keys table with FK to team, enabling multiple concurrent keys per team. Add CRUD endpoints for key management (create/list/delete) and a migration that carries existing keys over. The frontend replaces the single regenerate-key button with a full key management dialog supporting both auto-generated and manually provided keys. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/admin.py | 126 +++++++++++++---- backend/app/api/deps.py | 4 +- backend/app/models/__init__.py | 3 +- backend/app/models/api_key.py | 39 ++++++ backend/app/models/team.py | 25 +--- backend/app/schemas/__init__.py | 10 +- backend/app/schemas/team.py | 33 ++++- backend/migrations/004_api_keys_table.sql | 36 +++++ frontend/src/api/client.ts | 16 ++- frontend/src/stores/teams.ts | 22 ++- frontend/src/views/Dashboard.vue | 2 +- frontend/src/views/admin/Teams.vue | 156 ++++++++++++++++++++-- 12 files changed, 390 insertions(+), 82 deletions(-) create mode 100644 backend/app/models/api_key.py create mode 100644 backend/migrations/004_api_keys_table.sql diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 8336ce9..d2b71bc 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1,9 +1,10 @@ from uuid import UUID from fastapi import APIRouter, HTTPException, status -from app.models import User, Team, TeamMembership, TeamRole +from app.models import User, Team, TeamMembership, TeamRole, ApiKey from app.schemas import ( UserCreate, UserUpdate, UserResponse, - TeamCreate, TeamUpdate, TeamResponse, TeamWithKey, + TeamCreate, TeamUpdate, TeamResponse, TeamCreateResponse, + ApiKeyCreate, ApiKeyResponse, ApiKeyWithSecret, MembershipCreate, MembershipResponse, ) from app.api.deps import AdminUser @@ -94,35 +95,62 @@ async def delete_user(admin: AdminUser, user_id: UUID): # ============== Teams ============== +async def _team_response(team: Team) -> TeamResponse: + """Build a TeamResponse with prefetched api_keys.""" + keys = await ApiKey.filter(team=team).all() + return TeamResponse( + id=team.id, + name=team.name, + api_keys=[ApiKeyResponse.model_validate(k, from_attributes=True) for k in keys], + retention_days=team.retention_days, + created_at=team.created_at, + updated_at=team.updated_at, + ) + + @router.get("/teams", response_model=list[TeamResponse]) async def list_teams(admin: AdminUser): """List all teams.""" - teams = await Team.all() - return [TeamResponse.model_validate(t, from_attributes=True) for t in teams] + teams = await Team.all().prefetch_related("api_keys") + return [ + TeamResponse( + id=t.id, + name=t.name, + api_keys=[ApiKeyResponse.model_validate(k, from_attributes=True) for k in t.api_keys], + retention_days=t.retention_days, + created_at=t.created_at, + updated_at=t.updated_at, + ) + for t in teams + ] -@router.post("/teams", response_model=TeamWithKey, status_code=status.HTTP_201_CREATED) +@router.post("/teams", response_model=TeamCreateResponse, status_code=status.HTTP_201_CREATED) async def create_team(admin: AdminUser, data: TeamCreate): - """Create a new team. Returns the API key (shown only once).""" + """Create a new team. Returns the first API key (shown only once).""" existing = await Team.filter(name=data.name).first() if existing: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Team name already exists") - api_key, key_hash, prefix = Team.generate_api_key() - team = await Team.create( name=data.name, - api_key_hash=key_hash, - api_key_prefix=prefix, retention_days=data.retention_days, ) await create_team_partition(team.id) - return TeamWithKey( + api_key, key_hash, prefix = ApiKey.generate_api_key() + key_obj = await ApiKey.create( + team=team, + label="default", + api_key_hash=key_hash, + api_key_prefix=prefix, + ) + + return TeamCreateResponse( id=team.id, name=team.name, - api_key_prefix=team.api_key_prefix, + api_keys=[ApiKeyResponse.model_validate(key_obj, from_attributes=True)], retention_days=team.retention_days, created_at=team.created_at, updated_at=team.updated_at, @@ -136,7 +164,7 @@ async def get_team(admin: AdminUser, team_id: UUID): team = await Team.filter(id=team_id).first() if team is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found") - return TeamResponse.model_validate(team, from_attributes=True) + return await _team_response(team) @router.put("/teams/{team_id}", response_model=TeamResponse) @@ -156,7 +184,7 @@ async def update_team(admin: AdminUser, team_id: UUID, data: TeamUpdate): team.retention_days = data.retention_days await team.save() - return TeamResponse.model_validate(team, from_attributes=True) + return await _team_response(team) @router.delete("/teams/{team_id}") @@ -171,28 +199,68 @@ async def delete_team(admin: AdminUser, team_id: UUID): return {"message": "Team deleted"} -@router.post("/teams/{team_id}/regenerate-key", response_model=TeamWithKey) -async def regenerate_api_key(admin: AdminUser, team_id: UUID): - """Regenerate a team's API key. Returns the new key (shown only once).""" +# ============== API Keys ============== + +@router.get("/teams/{team_id}/api-keys", response_model=list[ApiKeyResponse]) +async def list_api_keys(admin: AdminUser, team_id: UUID): + """List all API keys for a team.""" team = await Team.filter(id=team_id).first() if team is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found") - api_key, key_hash, prefix = Team.generate_api_key() - team.api_key_hash = key_hash - team.api_key_prefix = prefix - await team.save() + keys = await ApiKey.filter(team=team).all() + return [ApiKeyResponse.model_validate(k, from_attributes=True) for k in keys] - return TeamWithKey( - id=team.id, - name=team.name, - api_key_prefix=team.api_key_prefix, - retention_days=team.retention_days, - created_at=team.created_at, - updated_at=team.updated_at, - api_key=api_key, + +@router.post("/teams/{team_id}/api-keys", response_model=ApiKeyWithSecret, status_code=status.HTTP_201_CREATED) +async def create_api_key(admin: AdminUser, team_id: UUID, data: ApiKeyCreate): + """Create a new API key for a team. If api_key is provided, use it; otherwise auto-generate.""" + team = await Team.filter(id=team_id).first() + if team is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found") + + if data.api_key: + # Manually provided key + full_key = data.api_key + key_hash = ApiKey.hash_api_key(full_key) + # Derive prefix from the key (first 10 chars or less) + prefix = full_key[:10] if len(full_key) > 10 else full_key + + # Check for duplicate hash + existing = await ApiKey.filter(api_key_hash=key_hash).first() + if existing: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This API key already exists") + else: + # Auto-generate + full_key, key_hash, prefix = ApiKey.generate_api_key() + + key_obj = await ApiKey.create( + team=team, + label=data.label, + api_key_hash=key_hash, + api_key_prefix=prefix, ) + return ApiKeyWithSecret( + id=key_obj.id, + team_id=team_id, + label=key_obj.label, + api_key_prefix=key_obj.api_key_prefix, + created_at=key_obj.created_at, + api_key=full_key, + ) + + +@router.delete("/teams/{team_id}/api-keys/{key_id}") +async def delete_api_key(admin: AdminUser, team_id: UUID, key_id: UUID): + """Revoke an API key.""" + key = await ApiKey.filter(id=key_id, team_id=team_id).first() + if key is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found") + + await key.delete() + return {"message": "API key revoked"} + # ============== Team Members ============== diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index d711730..b10ff5f 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -2,7 +2,7 @@ from uuid import UUID from fastapi import Depends, HTTPException, status, Header from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from app.models import User, Team, TeamMembership, TeamRole +from app.models import User, Team, TeamMembership, TeamRole, ApiKey from app.services.auth import AuthService security = HTTPBearer() @@ -47,7 +47,7 @@ async def get_team_from_api_key( x_api_key: Annotated[str, Header()] ) -> Team: """Validate API key and return the team.""" - team = await Team.get_by_api_key(x_api_key) + team = await ApiKey.get_team_by_api_key(x_api_key) if team is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2cd3106..3d30d47 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,6 @@ from app.models.user import User from app.models.team import Team, TeamMembership, TeamRole +from app.models.api_key import ApiKey from app.models.log import Log, LogLevel -__all__ = ["User", "Team", "TeamMembership", "TeamRole", "Log", "LogLevel"] +__all__ = ["User", "Team", "TeamMembership", "TeamRole", "ApiKey", "Log", "LogLevel"] diff --git a/backend/app/models/api_key.py b/backend/app/models/api_key.py new file mode 100644 index 0000000..7d913c7 --- /dev/null +++ b/backend/app/models/api_key.py @@ -0,0 +1,39 @@ +from tortoise import fields +from tortoise.models import Model +import secrets +import hashlib + + +class ApiKey(Model): + id = fields.UUIDField(pk=True) + team = fields.ForeignKeyField("models.Team", related_name="api_keys", on_delete=fields.CASCADE) + label = fields.CharField(max_length=255, default="") + api_key_hash = fields.CharField(max_length=255, unique=True, index=True) + api_key_prefix = fields.CharField(max_length=20) + created_at = fields.DatetimeField(auto_now_add=True) + + class Meta: + table = "api_keys" + + @staticmethod + def generate_api_key() -> tuple[str, str, str]: + """Generate a new API key. Returns (full_key, hash, prefix).""" + random_part = secrets.token_urlsafe(32) + prefix = f"sl_{secrets.token_urlsafe(4)}" + full_key = f"{prefix}_{random_part}" + key_hash = hashlib.sha256(full_key.encode()).hexdigest() + return full_key, key_hash, prefix + + @staticmethod + def hash_api_key(key: str) -> str: + """Hash an API key for comparison.""" + return hashlib.sha256(key.encode()).hexdigest() + + @classmethod + async def get_team_by_api_key(cls, api_key: str): + """Find a team by API key.""" + key_hash = cls.hash_api_key(api_key) + row = await cls.filter(api_key_hash=key_hash).select_related("team").first() + if row is None: + return None + return row.team diff --git a/backend/app/models/team.py b/backend/app/models/team.py index 3b671ba..92fcd32 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -1,8 +1,6 @@ from enum import Enum from tortoise import fields from tortoise.models import Model -import secrets -import hashlib class TeamRole(str, Enum): @@ -14,39 +12,18 @@ class TeamRole(str, Enum): class Team(Model): id = fields.UUIDField(pk=True) name = fields.CharField(max_length=255, unique=True) - api_key_hash = fields.CharField(max_length=255, unique=True, index=True) - api_key_prefix = fields.CharField(max_length=20) # For identification: sl_xxxx retention_days = fields.IntField(null=True) # null = keep forever created_at = fields.DatetimeField(auto_now_add=True) updated_at = fields.DatetimeField(auto_now=True) # Reverse relations memberships: fields.ReverseRelation["TeamMembership"] + api_keys: fields.ReverseRelation["ApiKey"] logs: fields.ReverseRelation["Log"] class Meta: table = "teams" - @staticmethod - def generate_api_key() -> tuple[str, str, str]: - """Generate a new API key. Returns (full_key, hash, prefix).""" - random_part = secrets.token_urlsafe(32) - prefix = f"sl_{secrets.token_urlsafe(4)}" - full_key = f"{prefix}_{random_part}" - key_hash = hashlib.sha256(full_key.encode()).hexdigest() - return full_key, key_hash, prefix - - @staticmethod - def hash_api_key(key: str) -> str: - """Hash an API key for comparison.""" - return hashlib.sha256(key.encode()).hexdigest() - - @classmethod - async def get_by_api_key(cls, api_key: str) -> "Team | None": - """Find a team by API key.""" - key_hash = cls.hash_api_key(api_key) - return await cls.filter(api_key_hash=key_hash).first() - class TeamMembership(Model): id = fields.UUIDField(pk=True) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 207f46f..6a0b61b 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,12 +1,18 @@ from app.schemas.auth import Token, TokenPayload, LoginRequest, RefreshRequest from app.schemas.user import UserCreate, UserUpdate, UserResponse -from app.schemas.team import TeamCreate, TeamUpdate, TeamResponse, TeamWithKey, MembershipCreate, MembershipResponse +from app.schemas.team import ( + TeamCreate, TeamUpdate, TeamResponse, TeamCreateResponse, + ApiKeyCreate, ApiKeyResponse, ApiKeyWithSecret, + MembershipCreate, MembershipResponse, +) from app.schemas.log import LogCreate, LogBatchCreate, LogResponse, LogSearchParams, UserIdBackfillRequest, UserIdBackfillResponse __all__ = [ "Token", "TokenPayload", "LoginRequest", "RefreshRequest", "UserCreate", "UserUpdate", "UserResponse", - "TeamCreate", "TeamUpdate", "TeamResponse", "TeamWithKey", "MembershipCreate", "MembershipResponse", + "TeamCreate", "TeamUpdate", "TeamResponse", "TeamCreateResponse", + "ApiKeyCreate", "ApiKeyResponse", "ApiKeyWithSecret", + "MembershipCreate", "MembershipResponse", "LogCreate", "LogBatchCreate", "LogResponse", "LogSearchParams", "UserIdBackfillRequest", "UserIdBackfillResponse", ] diff --git a/backend/app/schemas/team.py b/backend/app/schemas/team.py index bac3fe4..1dc0b31 100644 --- a/backend/app/schemas/team.py +++ b/backend/app/schemas/team.py @@ -14,10 +14,31 @@ class TeamUpdate(BaseModel): retention_days: int | None = None +class ApiKeyResponse(BaseModel): + id: UUID + team_id: UUID + label: str + api_key_prefix: str + created_at: datetime + + class Config: + from_attributes = True + + +class ApiKeyWithSecret(ApiKeyResponse): + """Response when creating an API key - includes the full key (shown only once).""" + api_key: str + + +class ApiKeyCreate(BaseModel): + label: str = "" + api_key: str | None = None + + class TeamResponse(BaseModel): id: UUID name: str - api_key_prefix: str + api_keys: list[ApiKeyResponse] retention_days: int | None created_at: datetime updated_at: datetime @@ -26,8 +47,14 @@ class Config: from_attributes = True -class TeamWithKey(TeamResponse): - """Response when creating a team - includes the full API key (shown only once).""" +class TeamCreateResponse(BaseModel): + """Response when creating a team - includes the first API key with secret.""" + id: UUID + name: str + api_keys: list[ApiKeyResponse] + retention_days: int | None + created_at: datetime + updated_at: datetime api_key: str diff --git a/backend/migrations/004_api_keys_table.sql b/backend/migrations/004_api_keys_table.sql new file mode 100644 index 0000000..9279473 --- /dev/null +++ b/backend/migrations/004_api_keys_table.sql @@ -0,0 +1,36 @@ +-- Migration: Multiple API keys per team +-- Creates api_keys table, migrates existing keys from teams, drops old columns + +-- Idempotent: skip if api_keys table already exists +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'api_keys') THEN + RAISE NOTICE 'api_keys table already exists, skipping migration'; + RETURN; + END IF; + + -- Create the api_keys table + CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + label VARCHAR(255) NOT NULL DEFAULT '', + api_key_hash VARCHAR(255) NOT NULL UNIQUE, + api_key_prefix VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX idx_api_keys_team_id ON api_keys(team_id); + CREATE INDEX idx_api_keys_hash ON api_keys(api_key_hash); + + -- Migrate existing keys from teams table + INSERT INTO api_keys (id, team_id, label, api_key_hash, api_key_prefix, created_at) + SELECT gen_random_uuid(), id, 'default', api_key_hash, api_key_prefix, created_at + FROM teams + WHERE api_key_hash IS NOT NULL; + + -- Drop old columns from teams + ALTER TABLE teams DROP COLUMN IF EXISTS api_key_hash; + ALTER TABLE teams DROP COLUMN IF EXISTS api_key_prefix; + + RAISE NOTICE 'api_keys table created and data migrated successfully'; +END $$; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f3b0e45..4754ed6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -63,16 +63,28 @@ export interface User { updated_at: string } +export interface ApiKey { + id: string + team_id: string + label: string + api_key_prefix: string + created_at: string +} + +export interface ApiKeyWithSecret extends ApiKey { + api_key: string +} + export interface Team { id: string name: string - api_key_prefix: string + api_keys: ApiKey[] retention_days: number | null created_at: string updated_at: string } -export interface TeamWithKey extends Team { +export interface TeamCreateResponse extends Team { api_key: string } diff --git a/frontend/src/stores/teams.ts b/frontend/src/stores/teams.ts index 0b7b9d4..5528f9f 100644 --- a/frontend/src/stores/teams.ts +++ b/frontend/src/stores/teams.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import api, { type Team, type TeamMembership } from '@/api/client' +import api, { type Team, type TeamMembership, type ApiKey, type ApiKeyWithSecret } from '@/api/client' export const useTeamsStore = defineStore('teams', () => { const teams = ref([]) @@ -42,11 +42,23 @@ export const useTeamsStore = defineStore('teams', () => { teams.value = teams.value.filter(t => t.id !== id) } - async function regenerateApiKey(id: string) { - const response = await api.post(`/admin/teams/${id}/regenerate-key`) + async function getApiKeys(teamId: string): Promise { + const response = await api.get(`/admin/teams/${teamId}/api-keys`) return response.data } + async function createApiKey(teamId: string, label?: string, apiKey?: string): Promise { + const body: Record = {} + if (label) body.label = label + if (apiKey) body.api_key = apiKey + const response = await api.post(`/admin/teams/${teamId}/api-keys`, body) + return response.data + } + + async function deleteApiKey(teamId: string, keyId: string) { + await api.delete(`/admin/teams/${teamId}/api-keys/${keyId}`) + } + async function getMembers(teamId: string): Promise { const response = await api.get(`/admin/teams/${teamId}/members`) return response.data @@ -76,7 +88,9 @@ export const useTeamsStore = defineStore('teams', () => { createTeam, updateTeam, deleteTeam, - regenerateApiKey, + getApiKeys, + createApiKey, + deleteApiKey, getMembers, addMember, removeMember, diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index ea24a70..eabd31e 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -11,7 +11,7 @@ {{ team.name }} - API Key: {{ team.api_key_prefix }}... + {{ team.api_keys.length }} API key{{ team.api_keys.length !== 1 ? 's' : '' }}
diff --git a/frontend/src/views/admin/Teams.vue b/frontend/src/views/admin/Teams.vue index 2c42dc8..cdd4984 100644 --- a/frontend/src/views/admin/Teams.vue +++ b/frontend/src/views/admin/Teams.vue @@ -15,8 +15,13 @@ :items="teams" :loading="loading" > -