This commit is contained in:
delete 2026-06-10 20:51:17 +03:00
commit b15b95781c
108 changed files with 14802 additions and 0 deletions

761
internal/db/apikeys.go Normal file
View file

@ -0,0 +1,761 @@
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
}