init
This commit is contained in:
commit
b15b95781c
108 changed files with 14802 additions and 0 deletions
32
internal/apikeys/context.go
Normal file
32
internal/apikeys/context.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package apikeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const requestContextKey contextKey = "api-key"
|
||||
|
||||
type AuthState struct {
|
||||
APIKey db.APIKey
|
||||
}
|
||||
|
||||
func NewContext(ctx context.Context, state *AuthState) context.Context {
|
||||
if state == nil {
|
||||
return ctx
|
||||
}
|
||||
|
||||
return context.WithValue(ctx, requestContextKey, *state)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) (*AuthState, bool) {
|
||||
state, ok := ctx.Value(requestContextKey).(AuthState)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &state, true
|
||||
}
|
||||
407
internal/apikeys/service.go
Normal file
407
internal/apikeys/service.go
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
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 }
|
||||
316
internal/apikeys/service_test.go
Normal file
316
internal/apikeys/service_test.go
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
package apikeys_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func TestServiceCreateAuthenticateAndRejectInactiveOrExpiredKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, store := newAPIKeyTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
project := createProject(t, ctx, store, "Desktop App", "desktop-app")
|
||||
|
||||
created, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Desktop Clients",
|
||||
ScopeMode: db.ScopeModeProjectAllowList,
|
||||
CanDownload: true,
|
||||
ProjectIDs: []int64{project.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create api key: %v", err)
|
||||
}
|
||||
|
||||
stored, err := store.APIKeys.GetByID(ctx, created.APIKey.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load stored api key: %v", err)
|
||||
}
|
||||
|
||||
if stored.KeyHash == created.RawKey {
|
||||
t.Fatal("expected raw api key to be hashed before storage")
|
||||
}
|
||||
|
||||
if stored.KeyPrefix == created.RawKey {
|
||||
t.Fatal("expected only a short key prefix to be stored")
|
||||
}
|
||||
|
||||
authState, err := service.Authenticate(ctx, created.RawKey)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticate api key: %v", err)
|
||||
}
|
||||
|
||||
if authState.APIKey.ID != created.APIKey.ID {
|
||||
t.Fatalf("expected authenticated api key id %d, got %d", created.APIKey.ID, authState.APIKey.ID)
|
||||
}
|
||||
|
||||
stored, err = store.APIKeys.GetByID(ctx, created.APIKey.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload stored api key: %v", err)
|
||||
}
|
||||
|
||||
if stored.LastUsedAt == nil {
|
||||
t.Fatal("expected successful authentication to update last_used_at")
|
||||
}
|
||||
|
||||
expiredAt := time.Now().UTC().Add(-time.Hour)
|
||||
expired, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Expired Clients",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
ExpiresAt: &expiredAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create expired api key: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.Authenticate(ctx, expired.RawKey); !errors.Is(err, apikeys.ErrUnauthenticated) {
|
||||
t.Fatalf("expected expired api key to be rejected, got %v", err)
|
||||
}
|
||||
|
||||
revoked, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Revoked Clients",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create revoked api key: %v", err)
|
||||
}
|
||||
|
||||
if err := service.SetActive(ctx, revoked.APIKey.ID, false); err != nil {
|
||||
t.Fatalf("revoke api key: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.Authenticate(ctx, revoked.RawKey); !errors.Is(err, apikeys.ErrUnauthenticated) {
|
||||
t.Fatalf("expected revoked api key to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListAccessibleProjectsByScopeMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, store := newAPIKeyTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
desktop := createProject(t, ctx, store, "Desktop App", "desktop-app")
|
||||
mobile := createProject(t, ctx, store, "Mobile App", "mobile-app")
|
||||
internal := createProject(t, ctx, store, "Internal App", "internal-app")
|
||||
archived := createProject(t, ctx, store, "Archived App", "archived-app")
|
||||
|
||||
windows := createTag(t, ctx, store, "Windows", "windows")
|
||||
beta := createTag(t, ctx, store, "Beta", "beta")
|
||||
|
||||
attachTag(t, ctx, store, desktop.ID, windows.ID)
|
||||
attachTag(t, ctx, store, mobile.ID, beta.ID)
|
||||
attachTag(t, ctx, store, archived.ID, windows.ID)
|
||||
|
||||
if err := store.Projects.SetActive(ctx, archived.ID, false); err != nil {
|
||||
t.Fatalf("archive project: %v", err)
|
||||
}
|
||||
|
||||
allProjectsKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "All Projects",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create all projects key: %v", err)
|
||||
}
|
||||
|
||||
projectAllowKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Project Allow",
|
||||
ScopeMode: db.ScopeModeProjectAllowList,
|
||||
CanDownload: true,
|
||||
ProjectIDs: []int64{mobile.ID, internal.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create project allow key: %v", err)
|
||||
}
|
||||
|
||||
projectDenyKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Project Deny",
|
||||
ScopeMode: db.ScopeModeProjectDenyList,
|
||||
CanDownload: true,
|
||||
ProjectIDs: []int64{mobile.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create project deny key: %v", err)
|
||||
}
|
||||
|
||||
tagAllowKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Tag Allow",
|
||||
ScopeMode: db.ScopeModeTagAllowList,
|
||||
CanDownload: true,
|
||||
TagIDs: []int64{windows.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create tag allow key: %v", err)
|
||||
}
|
||||
|
||||
tagDenyKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Tag Deny",
|
||||
ScopeMode: db.ScopeModeTagDenyList,
|
||||
CanDownload: true,
|
||||
TagIDs: []int64{windows.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create tag deny key: %v", err)
|
||||
}
|
||||
|
||||
assertAccessibleProjects(t, ctx, service, *allProjectsKey.APIKey, []string{"Desktop App", "Internal App", "Mobile App"})
|
||||
assertAccessibleProjects(t, ctx, service, *projectAllowKey.APIKey, []string{"Internal App", "Mobile App"})
|
||||
assertAccessibleProjects(t, ctx, service, *projectDenyKey.APIKey, []string{"Desktop App", "Internal App"})
|
||||
assertAccessibleProjects(t, ctx, service, *tagAllowKey.APIKey, []string{"Desktop App"})
|
||||
assertAccessibleProjects(t, ctx, service, *tagDenyKey.APIKey, []string{"Internal App", "Mobile App"})
|
||||
}
|
||||
|
||||
func TestServiceUpdateTransitionsScopeRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, store := newAPIKeyTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
project := createProject(t, ctx, store, "Desktop App", "desktop-app")
|
||||
otherProject := createProject(t, ctx, store, "Mobile App", "mobile-app")
|
||||
windows := createTag(t, ctx, store, "Windows", "windows")
|
||||
attachTag(t, ctx, store, project.ID, windows.ID)
|
||||
|
||||
created, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Transition Key",
|
||||
ScopeMode: db.ScopeModeProjectAllowList,
|
||||
CanDownload: true,
|
||||
ProjectIDs: []int64{otherProject.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create api key: %v", err)
|
||||
}
|
||||
|
||||
updated, err := service.Update(ctx, created.APIKey.ID, apikeys.UpdateParams{
|
||||
Name: "Transition Key",
|
||||
ScopeMode: db.ScopeModeTagAllowList,
|
||||
CanDownload: true,
|
||||
TagIDs: []int64{windows.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update api key scope: %v", err)
|
||||
}
|
||||
|
||||
projectAccess, err := service.ListProjectAccess(ctx, updated.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list project access: %v", err)
|
||||
}
|
||||
|
||||
if len(projectAccess) != 0 {
|
||||
t.Fatalf("expected project access rows to be cleared, got %d", len(projectAccess))
|
||||
}
|
||||
|
||||
tagAccess, err := service.ListTagAccess(ctx, updated.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list tag access: %v", err)
|
||||
}
|
||||
|
||||
if len(tagAccess) != 1 || tagAccess[0].ID != windows.ID {
|
||||
t.Fatalf("expected one retained tag access row, got %+v", tagAccess)
|
||||
}
|
||||
|
||||
assertAccessibleProjects(t, ctx, service, *updated, []string{"Desktop App"})
|
||||
}
|
||||
|
||||
func assertAccessibleProjects(t *testing.T, ctx context.Context, service *apikeys.Service, key db.APIKey, want []string) {
|
||||
t.Helper()
|
||||
|
||||
projects, err := service.ListAccessibleProjects(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("list accessible projects: %v", err)
|
||||
}
|
||||
|
||||
got := make([]string, 0, len(projects))
|
||||
for _, project := range projects {
|
||||
got = append(got, project.Name)
|
||||
}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected accessible projects %v, got %v", want, got)
|
||||
}
|
||||
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("expected accessible projects %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newAPIKeyTestService(t *testing.T) (*apikeys.Service, *db.Store) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
sqlitePath := filepath.Join(t.TempDir(), "apikeys.sqlite")
|
||||
|
||||
database, err := db.Open(ctx, sqlitePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(ctx, database, apiKeyProjectPath(t, "migrations")); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("migrate sqlite: %v", err)
|
||||
}
|
||||
|
||||
store := db.NewStore(database)
|
||||
t.Cleanup(func() {
|
||||
_ = store.Close()
|
||||
})
|
||||
|
||||
return apikeys.NewService(store), store
|
||||
}
|
||||
|
||||
func apiKeyProjectPath(t *testing.T, parts ...string) string {
|
||||
t.Helper()
|
||||
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve caller path")
|
||||
}
|
||||
|
||||
root := filepath.Join(filepath.Dir(filename), "..", "..")
|
||||
items := append([]string{root}, parts...)
|
||||
return filepath.Join(items...)
|
||||
}
|
||||
|
||||
func createProject(t *testing.T, ctx context.Context, store *db.Store, name, slug string) *db.Project {
|
||||
t.Helper()
|
||||
|
||||
project, err := store.Projects.Create(ctx, db.CreateProjectParams{Name: name, Slug: slug})
|
||||
if err != nil {
|
||||
t.Fatalf("create project %s: %v", name, err)
|
||||
}
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
func createTag(t *testing.T, ctx context.Context, store *db.Store, name, slug string) *db.Tag {
|
||||
t.Helper()
|
||||
|
||||
tag, err := store.Tags.Create(ctx, db.CreateTagParams{Name: name, Slug: slug})
|
||||
if err != nil {
|
||||
t.Fatalf("create tag %s: %v", name, err)
|
||||
}
|
||||
|
||||
return tag
|
||||
}
|
||||
|
||||
func attachTag(t *testing.T, ctx context.Context, store *db.Store, projectID, tagID int64) {
|
||||
t.Helper()
|
||||
|
||||
if err := store.Projects.AttachTag(ctx, projectID, tagID); err != nil {
|
||||
t.Fatalf("attach tag %d to project %d: %v", tagID, projectID, err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue