407 lines
11 KiB
Go
407 lines
11 KiB
Go
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 }
|