package apikeys import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "strings" "time" "update_server/internal/db" ) const ( rawKeyPrefix = "upsk_" rawKeyBytes = 32 rawKeyPreviewLength = 17 createRetryLimit = 4 ) var ( ErrUnauthenticated = errors.New("api key unauthenticated") ErrUnauthorized = errors.New("api key unauthorized") ) type Permission string const ( PermissionDownload Permission = "can_download" PermissionUpload Permission = "can_upload" PermissionDelete Permission = "can_delete" PermissionManageProjects Permission = "can_manage_projects" ) type Service struct { store *db.Store } type CreateParams struct { Name string Description string ScopeMode db.ScopeMode CanDownload bool CanUpload bool CanDelete bool CanManageProjects bool ExpiresAt *time.Time ProjectIDs []int64 TagIDs []int64 CreatedByUserID *int64 } type UpdateParams struct { Name string Description string ScopeMode db.ScopeMode CanDownload bool CanUpload bool CanDelete bool CanManageProjects bool ExpiresAt *time.Time ProjectIDs []int64 TagIDs []int64 } type CreateResult struct { APIKey *db.APIKey RawKey string } func NewService(store *db.Store) *Service { return &Service{store: store} } func (s *Service) Create(ctx context.Context, params CreateParams) (*CreateResult, error) { normalized, err := normalizeCreateParams(params) if err != nil { return nil, err } for attempt := 0; attempt < createRetryLimit; attempt++ { rawKey, keyPrefix, keyHash, err := generateAPIKey() if err != nil { return nil, err } var created *db.APIKey err = s.store.WithTx(ctx, func(tx *db.TxStore) error { inserted, err := tx.APIKeys.Create(ctx, db.CreateAPIKeyParams{ Name: normalized.Name, KeyPrefix: keyPrefix, KeyHash: keyHash, Description: normalized.Description, ScopeMode: normalized.ScopeMode, CanDownload: normalized.CanDownload, CanUpload: normalized.CanUpload, CanDelete: normalized.CanDelete, CanManageProjects: normalized.CanManageProjects, IsActive: true, ExpiresAt: normalized.ExpiresAt, CreatedByUserID: normalized.CreatedByUserID, }) if err != nil { return err } if err := syncScopeAccess(ctx, tx.APIKeys, inserted.ID, normalized.ScopeMode, normalized.ProjectIDs, normalized.TagIDs); err != nil { return err } created = inserted return nil }) if err != nil { if errors.Is(err, db.ErrConflict) { continue } return nil, fmt.Errorf("create api key: %w", err) } return &CreateResult{ APIKey: created, RawKey: rawKey, }, nil } return nil, fmt.Errorf("create api key: could not generate a unique key") } func (s *Service) Update(ctx context.Context, apiKeyID int64, params UpdateParams) (*db.APIKey, error) { normalized, err := normalizeUpdateParams(params) if err != nil { return nil, err } var updated *db.APIKey if err := s.store.WithTx(ctx, func(tx *db.TxStore) error { if _, err := tx.APIKeys.GetByID(ctx, apiKeyID); err != nil { return err } switch { case normalized.ScopeMode == db.ScopeModeAllProjects: if err := tx.APIKeys.ClearProjectAccess(ctx, apiKeyID); err != nil { return err } if err := tx.APIKeys.ClearTagAccess(ctx, apiKeyID); err != nil { return err } case normalized.ScopeMode.UsesProjectRules(): if err := tx.APIKeys.ClearTagAccess(ctx, apiKeyID); err != nil { return err } case normalized.ScopeMode.UsesTagRules(): if err := tx.APIKeys.ClearProjectAccess(ctx, apiKeyID); err != nil { return err } } record, err := tx.APIKeys.Update(ctx, apiKeyID, db.UpdateAPIKeyParams{ Name: normalized.Name, Description: normalized.Description, ScopeMode: normalized.ScopeMode, CanDownload: normalized.CanDownload, CanUpload: normalized.CanUpload, CanDelete: normalized.CanDelete, CanManageProjects: normalized.CanManageProjects, ExpiresAt: normalized.ExpiresAt, }) if err != nil { return err } if err := syncScopeAccess(ctx, tx.APIKeys, apiKeyID, normalized.ScopeMode, normalized.ProjectIDs, normalized.TagIDs); err != nil { return err } updated = record return nil }); err != nil { if errors.Is(err, db.ErrNotFound) { return nil, err } return nil, fmt.Errorf("update api key: %w", err) } return s.store.APIKeys.GetByID(ctx, updated.ID) } func (s *Service) SetActive(ctx context.Context, apiKeyID int64, isActive bool) error { if err := s.store.APIKeys.SetActive(ctx, apiKeyID, isActive); err != nil { return err } return nil } func (s *Service) Authenticate(ctx context.Context, rawKey string) (*AuthState, error) { rawKey = strings.TrimSpace(rawKey) if rawKey == "" { return nil, ErrUnauthenticated } record, err := s.store.APIKeys.GetByHash(ctx, hashAPIKey(rawKey)) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, ErrUnauthenticated } return nil, fmt.Errorf("lookup api key: %w", err) } now := time.Now().UTC() if !record.IsActive || record.Expired(now) { return nil, ErrUnauthenticated } if err := s.store.APIKeys.TouchLastUsedAt(ctx, record.ID, now); err != nil { return nil, fmt.Errorf("touch api key last_used_at: %w", err) } record.LastUsedAt = &now return &AuthState{APIKey: *record}, nil } func (s *Service) List(ctx context.Context) ([]db.APIKeyListItem, error) { return s.store.APIKeys.List(ctx) } func (s *Service) GetByID(ctx context.Context, apiKeyID int64) (*db.APIKey, error) { return s.store.APIKeys.GetByID(ctx, apiKeyID) } func (s *Service) ListProjectAccess(ctx context.Context, apiKeyID int64) ([]db.Project, error) { return s.store.APIKeys.ListProjectAccess(ctx, apiKeyID) } func (s *Service) ListTagAccess(ctx context.Context, apiKeyID int64) ([]db.Tag, error) { return s.store.APIKeys.ListTagAccess(ctx, apiKeyID) } func (s *Service) ListAccessibleProjects(ctx context.Context, apiKey db.APIKey) ([]db.Project, error) { return s.store.APIKeys.ListAccessibleProjects(ctx, apiKey.ID, apiKey.ScopeMode) } func (s *Service) CanAccessProject(ctx context.Context, apiKey db.APIKey, projectID int64) (bool, error) { return s.store.APIKeys.HasProjectAccess(ctx, apiKey.ID, apiKey.ScopeMode, projectID) } func HasPermission(apiKey db.APIKey, permission Permission) bool { switch permission { case PermissionDownload: return apiKey.CanDownload case PermissionUpload: return apiKey.CanUpload case PermissionDelete: return apiKey.CanDelete case PermissionManageProjects: return apiKey.CanManageProjects default: return false } } func generateAPIKey() (rawKey string, keyPrefix string, keyHash string, err error) { bytes := make([]byte, rawKeyBytes) if _, err := rand.Read(bytes); err != nil { return "", "", "", fmt.Errorf("generate api key: %w", err) } body := base64.RawURLEncoding.EncodeToString(bytes) rawKey = rawKeyPrefix + body keyPrefix = rawKey if len(keyPrefix) > rawKeyPreviewLength { keyPrefix = keyPrefix[:rawKeyPreviewLength] } return rawKey, keyPrefix, hashAPIKey(rawKey), nil } func hashAPIKey(rawKey string) string { sum := sha256.Sum256([]byte(rawKey)) return hex.EncodeToString(sum[:]) } func syncScopeAccess(ctx context.Context, repo *db.APIKeyRepository, apiKeyID int64, scopeMode db.ScopeMode, projectIDs []int64, tagIDs []int64) error { switch { case scopeMode == db.ScopeModeAllProjects: if err := repo.ClearProjectAccess(ctx, apiKeyID); err != nil { return err } if err := repo.ClearTagAccess(ctx, apiKeyID); err != nil { return err } case scopeMode.UsesProjectRules(): if err := repo.ClearTagAccess(ctx, apiKeyID); err != nil { return err } if err := repo.ReplaceProjectAccess(ctx, apiKeyID, projectIDs); err != nil { return err } case scopeMode.UsesTagRules(): if err := repo.ClearProjectAccess(ctx, apiKeyID); err != nil { return err } if err := repo.ReplaceTagAccess(ctx, apiKeyID, tagIDs); err != nil { return err } default: return fmt.Errorf("unsupported scope mode %q", scopeMode) } return nil } func normalizeCreateParams(params CreateParams) (CreateParams, error) { normalized := params normalized.Name = strings.TrimSpace(normalized.Name) normalized.Description = strings.TrimSpace(normalized.Description) switch { case normalized.Name == "": return CreateParams{}, fmt.Errorf("api key name is required") case !normalized.ScopeMode.Valid(): return CreateParams{}, fmt.Errorf("api key scope mode is required") case !hasAnyPermission(normalized): return CreateParams{}, fmt.Errorf("select at least one permission") } normalized.ProjectIDs, normalized.TagIDs = normalizedScopeIDs(normalized.ScopeMode, normalized.ProjectIDs, normalized.TagIDs) return normalized, nil } func normalizeUpdateParams(params UpdateParams) (UpdateParams, error) { normalized := params normalized.Name = strings.TrimSpace(normalized.Name) normalized.Description = strings.TrimSpace(normalized.Description) switch { case normalized.Name == "": return UpdateParams{}, fmt.Errorf("api key name is required") case !normalized.ScopeMode.Valid(): return UpdateParams{}, fmt.Errorf("api key scope mode is required") case !hasAnyPermission(normalized): return UpdateParams{}, fmt.Errorf("select at least one permission") } normalized.ProjectIDs, normalized.TagIDs = normalizedScopeIDs(normalized.ScopeMode, normalized.ProjectIDs, normalized.TagIDs) return normalized, nil } func normalizedScopeIDs(scopeMode db.ScopeMode, projectIDs []int64, tagIDs []int64) ([]int64, []int64) { switch { case scopeMode.UsesProjectRules(): return dedupeIDs(projectIDs), nil case scopeMode.UsesTagRules(): return nil, dedupeIDs(tagIDs) default: return nil, nil } } func dedupeIDs(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) deduped := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} deduped = append(deduped, value) } return deduped } func hasAnyPermission(params interface { GetCanDownload() bool GetCanUpload() bool GetCanDelete() bool GetCanManageProjects() bool }) bool { return params.GetCanDownload() || params.GetCanUpload() || params.GetCanDelete() || params.GetCanManageProjects() } func (p CreateParams) GetCanDownload() bool { return p.CanDownload } func (p CreateParams) GetCanUpload() bool { return p.CanUpload } func (p CreateParams) GetCanDelete() bool { return p.CanDelete } func (p CreateParams) GetCanManageProjects() bool { return p.CanManageProjects } func (p UpdateParams) GetCanDownload() bool { return p.CanDownload } func (p UpdateParams) GetCanUpload() bool { return p.CanUpload } func (p UpdateParams) GetCanDelete() bool { return p.CanDelete } func (p UpdateParams) GetCanManageProjects() bool { return p.CanManageProjects }