package db import ( "context" "database/sql" "errors" "fmt" "sort" "strings" "time" ) type CreateAPIKeyParams struct { Name string KeyPrefix string KeyHash string Description string ScopeMode ScopeMode CanDownload bool CanUpload bool CanDelete bool CanManageProjects bool IsActive bool ExpiresAt *time.Time CreatedByUserID *int64 } type UpdateAPIKeyParams struct { Name string Description string ScopeMode ScopeMode CanDownload bool CanUpload bool CanDelete bool CanManageProjects bool ExpiresAt *time.Time } func (r *APIKeyRepository) List(ctx context.Context) ([]APIKeyListItem, error) { rows, err := r.q.QueryContext( ctx, `SELECT ak.id, ak.name, ak.key_prefix, ak.key_hash, ak.description, ak.scope_mode, ak.can_download, ak.can_upload, ak.can_delete, ak.can_manage_projects, ak.is_active, ak.expires_at, ak.created_at, ak.updated_at, ak.last_used_at, ak.created_by_user_id, COUNT(DISTINCT ap.project_id) AS project_rule_count, COUNT(DISTINCT at.tag_id) AS tag_rule_count FROM api_keys AS ak LEFT JOIN api_key_project_access AS ap ON ap.api_key_id = ak.id LEFT JOIN api_key_tag_access AS at ON at.api_key_id = ak.id GROUP BY ak.id ORDER BY ak.is_active DESC, ak.updated_at DESC, ak.created_at DESC, ak.name COLLATE NOCASE`, ) if err != nil { return nil, fmt.Errorf("query api keys: %w", err) } defer rows.Close() items := make([]APIKeyListItem, 0) for rows.Next() { item, err := scanAPIKeyListItem(rows) if err != nil { return nil, fmt.Errorf("scan api key list item: %w", err) } items = append(items, *item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate api keys: %w", err) } return items, nil } func (r *APIKeyRepository) GetByID(ctx context.Context, id int64) (*APIKey, error) { key, err := scanAPIKey(r.q.QueryRowContext( ctx, `SELECT id, name, key_prefix, key_hash, description, scope_mode, can_download, can_upload, can_delete, can_manage_projects, is_active, expires_at, created_at, updated_at, last_used_at, created_by_user_id FROM api_keys WHERE id = ?`, id, )) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("scan api key by id: %w", err) } return key, nil } func (r *APIKeyRepository) GetByHash(ctx context.Context, keyHash string) (*APIKey, error) { key, err := scanAPIKey(r.q.QueryRowContext( ctx, `SELECT id, name, key_prefix, key_hash, description, scope_mode, can_download, can_upload, can_delete, can_manage_projects, is_active, expires_at, created_at, updated_at, last_used_at, created_by_user_id FROM api_keys WHERE key_hash = ? LIMIT 1`, strings.TrimSpace(keyHash), )) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("scan api key by hash: %w", err) } return key, nil } func (r *APIKeyRepository) Create(ctx context.Context, params CreateAPIKeyParams) (*APIKey, error) { isActive := 0 if params.IsActive { isActive = 1 } result, err := r.q.ExecContext( ctx, `INSERT INTO api_keys ( name, key_prefix, key_hash, description, scope_mode, can_download, can_upload, can_delete, can_manage_projects, is_active, expires_at, created_by_user_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, strings.TrimSpace(params.Name), strings.TrimSpace(params.KeyPrefix), strings.TrimSpace(params.KeyHash), strings.TrimSpace(params.Description), params.ScopeMode, boolToInt(params.CanDownload), boolToInt(params.CanUpload), boolToInt(params.CanDelete), boolToInt(params.CanManageProjects), isActive, nullableTimestampValue(params.ExpiresAt), params.CreatedByUserID, ) if err != nil { if isUniqueConstraintError(err) { return nil, errors.Join(ErrConflict, fmt.Errorf("insert api key: %w", err)) } return nil, fmt.Errorf("insert api key: %w", err) } apiKeyID, err := result.LastInsertId() if err != nil { return nil, fmt.Errorf("load inserted api key id: %w", err) } return r.GetByID(ctx, apiKeyID) } func (r *APIKeyRepository) Update(ctx context.Context, apiKeyID int64, params UpdateAPIKeyParams) (*APIKey, error) { result, err := r.q.ExecContext( ctx, `UPDATE api_keys SET name = ?, description = ?, scope_mode = ?, can_download = ?, can_upload = ?, can_delete = ?, can_manage_projects = ?, expires_at = ? WHERE id = ?`, strings.TrimSpace(params.Name), strings.TrimSpace(params.Description), params.ScopeMode, boolToInt(params.CanDownload), boolToInt(params.CanUpload), boolToInt(params.CanDelete), boolToInt(params.CanManageProjects), nullableTimestampValue(params.ExpiresAt), apiKeyID, ) if err != nil { return nil, fmt.Errorf("update api key: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return nil, fmt.Errorf("read updated api key rows: %w", err) } if rowsAffected == 0 { return nil, ErrNotFound } return r.GetByID(ctx, apiKeyID) } func (r *APIKeyRepository) SetActive(ctx context.Context, apiKeyID int64, isActive bool) error { result, err := r.q.ExecContext( ctx, `UPDATE api_keys SET is_active = ? WHERE id = ?`, boolToInt(isActive), apiKeyID, ) if err != nil { return fmt.Errorf("update api key active state: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("read updated api key rows: %w", err) } if rowsAffected == 0 { return ErrNotFound } return nil } func (r *APIKeyRepository) TouchLastUsedAt(ctx context.Context, apiKeyID int64, usedAt time.Time) error { result, err := r.q.ExecContext( ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, formatTimestamp(usedAt), apiKeyID, ) if err != nil { return fmt.Errorf("update api key last_used_at: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("read updated api key rows: %w", err) } if rowsAffected == 0 { return ErrNotFound } return nil } func (r *APIKeyRepository) ListProjectAccess(ctx context.Context, apiKeyID int64) ([]Project, error) { rows, err := r.q.QueryContext( ctx, `SELECT p.id, p.name, p.slug, p.description, p.is_active, p.created_at, p.updated_at FROM projects AS p INNER JOIN api_key_project_access AS ap ON ap.project_id = p.id WHERE ap.api_key_id = ? ORDER BY p.name COLLATE NOCASE`, apiKeyID, ) if err != nil { return nil, fmt.Errorf("query api key project access: %w", err) } defer rows.Close() projects := make([]Project, 0) for rows.Next() { project, err := scanProject(rows) if err != nil { return nil, fmt.Errorf("scan api key project access: %w", err) } projects = append(projects, *project) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate api key project access: %w", err) } return projects, nil } func (r *APIKeyRepository) ListTagAccess(ctx context.Context, apiKeyID int64) ([]Tag, error) { rows, err := r.q.QueryContext( ctx, `SELECT t.id, t.name, t.slug, t.description, t.created_at, t.updated_at FROM tags AS t INNER JOIN api_key_tag_access AS at ON at.tag_id = t.id WHERE at.api_key_id = ? ORDER BY t.name COLLATE NOCASE`, apiKeyID, ) if err != nil { return nil, fmt.Errorf("query api key tag access: %w", err) } defer rows.Close() tags := make([]Tag, 0) for rows.Next() { tag, err := scanTag(rows) if err != nil { return nil, fmt.Errorf("scan api key tag access: %w", err) } tags = append(tags, *tag) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate api key tag access: %w", err) } return tags, nil } func (r *APIKeyRepository) ReplaceProjectAccess(ctx context.Context, apiKeyID int64, projectIDs []int64) error { if err := r.ClearProjectAccess(ctx, apiKeyID); err != nil { return err } for _, projectID := range normalizeIDList(projectIDs) { if _, err := r.q.ExecContext( ctx, `INSERT OR IGNORE INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`, apiKeyID, projectID, ); err != nil { return fmt.Errorf("insert api key project access: %w", err) } } return nil } func (r *APIKeyRepository) ReplaceTagAccess(ctx context.Context, apiKeyID int64, tagIDs []int64) error { if err := r.ClearTagAccess(ctx, apiKeyID); err != nil { return err } for _, tagID := range normalizeIDList(tagIDs) { if _, err := r.q.ExecContext( ctx, `INSERT OR IGNORE INTO api_key_tag_access (api_key_id, tag_id) VALUES (?, ?)`, apiKeyID, tagID, ); err != nil { return fmt.Errorf("insert api key tag access: %w", err) } } return nil } func (r *APIKeyRepository) ClearProjectAccess(ctx context.Context, apiKeyID int64) error { if _, err := r.q.ExecContext(ctx, `DELETE FROM api_key_project_access WHERE api_key_id = ?`, apiKeyID); err != nil { return fmt.Errorf("delete api key project access: %w", err) } return nil } func (r *APIKeyRepository) ClearTagAccess(ctx context.Context, apiKeyID int64) error { if _, err := r.q.ExecContext(ctx, `DELETE FROM api_key_tag_access WHERE api_key_id = ?`, apiKeyID); err != nil { return fmt.Errorf("delete api key tag access: %w", err) } return nil } func (r *APIKeyRepository) ListAccessibleProjects(ctx context.Context, apiKeyID int64, scopeMode ScopeMode) ([]Project, error) { rows, err := r.q.QueryContext( ctx, `SELECT p.id, p.name, p.slug, p.description, p.is_active, p.created_at, p.updated_at FROM projects AS p WHERE p.is_active = 1 AND ( ? = 'all_projects' OR ( ? = 'project_allow_list' AND EXISTS ( SELECT 1 FROM api_key_project_access AS ap WHERE ap.api_key_id = ? AND ap.project_id = p.id ) ) OR ( ? = 'project_deny_list' AND NOT EXISTS ( SELECT 1 FROM api_key_project_access AS ap WHERE ap.api_key_id = ? AND ap.project_id = p.id ) ) OR ( ? = 'tag_allow_list' AND EXISTS ( SELECT 1 FROM project_tags AS pt INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id WHERE at.api_key_id = ? AND pt.project_id = p.id ) ) OR ( ? = 'tag_deny_list' AND NOT EXISTS ( SELECT 1 FROM project_tags AS pt INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id WHERE at.api_key_id = ? AND pt.project_id = p.id ) ) ) ORDER BY p.name COLLATE NOCASE`, scopeMode, scopeMode, apiKeyID, scopeMode, apiKeyID, scopeMode, apiKeyID, scopeMode, apiKeyID, ) if err != nil { return nil, fmt.Errorf("query accessible projects: %w", err) } defer rows.Close() projects := make([]Project, 0) for rows.Next() { project, err := scanProject(rows) if err != nil { return nil, fmt.Errorf("scan accessible project: %w", err) } projects = append(projects, *project) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate accessible projects: %w", err) } return projects, nil } func (r *APIKeyRepository) HasProjectAccess(ctx context.Context, apiKeyID int64, scopeMode ScopeMode, projectID int64) (bool, error) { var exists int if err := r.q.QueryRowContext( ctx, `SELECT EXISTS( SELECT 1 FROM projects AS p WHERE p.id = ? AND p.is_active = 1 AND ( ? = 'all_projects' OR ( ? = 'project_allow_list' AND EXISTS ( SELECT 1 FROM api_key_project_access AS ap WHERE ap.api_key_id = ? AND ap.project_id = p.id ) ) OR ( ? = 'project_deny_list' AND NOT EXISTS ( SELECT 1 FROM api_key_project_access AS ap WHERE ap.api_key_id = ? AND ap.project_id = p.id ) ) OR ( ? = 'tag_allow_list' AND EXISTS ( SELECT 1 FROM project_tags AS pt INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id WHERE at.api_key_id = ? AND pt.project_id = p.id ) ) OR ( ? = 'tag_deny_list' AND NOT EXISTS ( SELECT 1 FROM project_tags AS pt INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id WHERE at.api_key_id = ? AND pt.project_id = p.id ) ) ) )`, projectID, scopeMode, scopeMode, apiKeyID, scopeMode, apiKeyID, scopeMode, apiKeyID, scopeMode, apiKeyID, ).Scan(&exists); err != nil { return false, fmt.Errorf("query api key project access: %w", err) } return exists == 1, nil } func scanAPIKey(scanner rowScanner) (*APIKey, error) { var ( key APIKey scopeMode string canDownload int canUpload int canDelete int canManageProjects int isActive int expiresAtRaw sql.NullString createdAtRaw string updatedAtRaw string lastUsedAtRaw sql.NullString createdByUserIDRaw sql.NullInt64 ) if err := scanner.Scan( &key.ID, &key.Name, &key.KeyPrefix, &key.KeyHash, &key.Description, &scopeMode, &canDownload, &canUpload, &canDelete, &canManageProjects, &isActive, &expiresAtRaw, &createdAtRaw, &updatedAtRaw, &lastUsedAtRaw, &createdByUserIDRaw, ); err != nil { return nil, err } createdAt, err := parseTimestamp(createdAtRaw) if err != nil { return nil, fmt.Errorf("parse api key created_at: %w", err) } updatedAt, err := parseTimestamp(updatedAtRaw) if err != nil { return nil, fmt.Errorf("parse api key updated_at: %w", err) } expiresAt, err := parseNullableTimestamp(expiresAtRaw) if err != nil { return nil, fmt.Errorf("parse api key expires_at: %w", err) } lastUsedAt, err := parseNullableTimestamp(lastUsedAtRaw) if err != nil { return nil, fmt.Errorf("parse api key last_used_at: %w", err) } key.ScopeMode = ScopeMode(scopeMode) key.CanDownload = canDownload == 1 key.CanUpload = canUpload == 1 key.CanDelete = canDelete == 1 key.CanManageProjects = canManageProjects == 1 key.IsActive = isActive == 1 key.ExpiresAt = expiresAt key.CreatedAt = createdAt key.UpdatedAt = updatedAt key.LastUsedAt = lastUsedAt if createdByUserIDRaw.Valid { key.CreatedByUserID = &createdByUserIDRaw.Int64 } return &key, nil } func scanAPIKeyListItem(scanner rowScanner) (*APIKeyListItem, error) { var ( item APIKeyListItem scopeMode string canDownload int canUpload int canDelete int canManageProjects int isActive int expiresAtRaw sql.NullString createdAtRaw string updatedAtRaw string lastUsedAtRaw sql.NullString createdByUserIDRaw sql.NullInt64 ) if err := scanner.Scan( &item.APIKey.ID, &item.APIKey.Name, &item.APIKey.KeyPrefix, &item.APIKey.KeyHash, &item.APIKey.Description, &scopeMode, &canDownload, &canUpload, &canDelete, &canManageProjects, &isActive, &expiresAtRaw, &createdAtRaw, &updatedAtRaw, &lastUsedAtRaw, &createdByUserIDRaw, &item.ProjectRuleCount, &item.TagRuleCount, ); err != nil { return nil, err } createdAt, err := parseTimestamp(createdAtRaw) if err != nil { return nil, fmt.Errorf("parse api key list created_at: %w", err) } updatedAt, err := parseTimestamp(updatedAtRaw) if err != nil { return nil, fmt.Errorf("parse api key list updated_at: %w", err) } expiresAt, err := parseNullableTimestamp(expiresAtRaw) if err != nil { return nil, fmt.Errorf("parse api key list expires_at: %w", err) } lastUsedAt, err := parseNullableTimestamp(lastUsedAtRaw) if err != nil { return nil, fmt.Errorf("parse api key list last_used_at: %w", err) } item.APIKey.ScopeMode = ScopeMode(scopeMode) item.APIKey.CanDownload = canDownload == 1 item.APIKey.CanUpload = canUpload == 1 item.APIKey.CanDelete = canDelete == 1 item.APIKey.CanManageProjects = canManageProjects == 1 item.APIKey.IsActive = isActive == 1 item.APIKey.ExpiresAt = expiresAt item.APIKey.CreatedAt = createdAt item.APIKey.UpdatedAt = updatedAt item.APIKey.LastUsedAt = lastUsedAt if createdByUserIDRaw.Valid { item.APIKey.CreatedByUserID = &createdByUserIDRaw.Int64 } return &item, nil } func boolToInt(value bool) int { if value { return 1 } return 0 } func nullableTimestampValue(value *time.Time) any { if value == nil { return nil } return formatTimestamp(value.UTC()) } func normalizeIDList(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) normalized := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} normalized = append(normalized, value) } sort.Slice(normalized, func(i, j int) bool { return normalized[i] < normalized[j] }) return normalized }