From 828b7c7c345df2dcdbe57f8a80380a8dcbfb937f Mon Sep 17 00:00:00 2001 From: Andrej Mickov Date: Mon, 2 Mar 2026 23:37:33 +0100 Subject: [PATCH] add auth service user create/list/get primitives --- auth/errors.go | 3 + auth/service.go | 236 +++++++++++++++++++++++++++++++++++++++++++ metadata/metadata.go | 47 +++++++++ 3 files changed, 286 insertions(+) diff --git a/auth/errors.go b/auth/errors.go index 6b5766a..1c6b65a 100644 --- a/auth/errors.go +++ b/auth/errors.go @@ -5,6 +5,9 @@ import "errors" var ( ErrAccessDenied = errors.New("access denied") ErrInvalidAccessKeyID = errors.New("invalid access key id") + ErrUserAlreadyExists = errors.New("user already exists") + ErrUserNotFound = errors.New("user not found") + ErrInvalidUserInput = errors.New("invalid user input") ErrSignatureDoesNotMatch = errors.New("signature does not match") ErrAuthorizationHeaderMalformed = errors.New("authorization header malformed") ErrRequestTimeTooSkewed = errors.New("request time too skewed") diff --git a/auth/service.go b/auth/service.go index eea57a7..f5ed690 100644 --- a/auth/service.go +++ b/auth/service.go @@ -1,11 +1,15 @@ package auth import ( + "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" + "fs/metadata" "fs/models" "net/http" + "regexp" "strings" "time" ) @@ -13,10 +17,40 @@ import ( type Store interface { GetAuthIdentity(accessKeyID string) (*models.AuthIdentity, error) PutAuthIdentity(identity *models.AuthIdentity) error + ListAuthIdentities(limit int, after string) ([]models.AuthIdentity, string, error) GetAuthPolicy(accessKeyID string) (*models.AuthPolicy, error) PutAuthPolicy(policy *models.AuthPolicy) error } +type CreateUserInput struct { + AccessKeyID string + SecretKey string + Status string + Policy models.AuthPolicy +} + +type UserSummary struct { + AccessKeyID string + Status string + CreatedAt int64 + UpdatedAt int64 +} + +type UserDetails struct { + AccessKeyID string + Status string + CreatedAt int64 + UpdatedAt int64 + Policy models.AuthPolicy +} + +type CreateUserResult struct { + UserDetails + SecretKey string +} + +var validAccessKeyID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$`) + type Service struct { cfg Config store Store @@ -160,6 +194,140 @@ func (s *Service) AuthenticateRequest(r *http.Request) (RequestContext, error) { }, nil } +func (s *Service) CreateUser(input CreateUserInput) (*CreateUserResult, error) { + if !s.cfg.Enabled { + return nil, ErrAuthNotEnabled + } + + accessKeyID := strings.TrimSpace(input.AccessKeyID) + if !validAccessKeyID.MatchString(accessKeyID) { + return nil, fmt.Errorf("%w: invalid access key id", ErrInvalidUserInput) + } + + secretKey := strings.TrimSpace(input.SecretKey) + if secretKey == "" { + generated, err := generateSecretKey(32) + if err != nil { + return nil, err + } + secretKey = generated + } + if len(secretKey) < 8 { + return nil, fmt.Errorf("%w: secret key must be at least 8 characters", ErrInvalidUserInput) + } + + status := normalizeUserStatus(input.Status) + if status == "" { + return nil, fmt.Errorf("%w: status must be active or disabled", ErrInvalidUserInput) + } + + policy, err := normalizePolicy(input.Policy, accessKeyID) + if err != nil { + return nil, err + } + + existing, err := s.store.GetAuthIdentity(accessKeyID) + if err == nil && existing != nil { + return nil, ErrUserAlreadyExists + } + if err != nil && !errors.Is(err, metadata.ErrAuthIdentityNotFound) { + return nil, err + } + + now := s.now().Unix() + ciphertext, nonce, err := encryptSecret(s.masterKey, accessKeyID, secretKey) + if err != nil { + return nil, err + } + identity := &models.AuthIdentity{ + AccessKeyID: accessKeyID, + SecretEnc: ciphertext, + SecretNonce: nonce, + EncAlg: "AES-256-GCM", + KeyVersion: "v1", + Status: status, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.store.PutAuthIdentity(identity); err != nil { + return nil, err + } + if err := s.store.PutAuthPolicy(&policy); err != nil { + return nil, err + } + + return &CreateUserResult{ + UserDetails: UserDetails{ + AccessKeyID: accessKeyID, + Status: status, + CreatedAt: now, + UpdatedAt: now, + Policy: policy, + }, + SecretKey: secretKey, + }, nil +} + +func (s *Service) ListUsers(limit int, cursor string) ([]UserSummary, string, error) { + if !s.cfg.Enabled { + return nil, "", ErrAuthNotEnabled + } + if limit <= 0 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + + identities, nextCursor, err := s.store.ListAuthIdentities(limit, cursor) + if err != nil { + return nil, "", err + } + users := make([]UserSummary, 0, len(identities)) + for _, identity := range identities { + users = append(users, UserSummary{ + AccessKeyID: identity.AccessKeyID, + Status: normalizeUserStatus(identity.Status), + CreatedAt: identity.CreatedAt, + UpdatedAt: identity.UpdatedAt, + }) + } + return users, nextCursor, nil +} + +func (s *Service) GetUser(accessKeyID string) (*UserDetails, error) { + if !s.cfg.Enabled { + return nil, ErrAuthNotEnabled + } + accessKeyID = strings.TrimSpace(accessKeyID) + if accessKeyID == "" { + return nil, fmt.Errorf("%w: access key id is required", ErrInvalidUserInput) + } + + identity, err := s.store.GetAuthIdentity(accessKeyID) + if err != nil { + if errors.Is(err, metadata.ErrAuthIdentityNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + policy, err := s.store.GetAuthPolicy(accessKeyID) + if err != nil { + if errors.Is(err, metadata.ErrAuthPolicyNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + + return &UserDetails{ + AccessKeyID: identity.AccessKeyID, + Status: normalizeUserStatus(identity.Status), + CreatedAt: identity.CreatedAt, + UpdatedAt: identity.UpdatedAt, + Policy: *policy, + }, nil +} + func parsePolicyJSON(raw string) (*models.AuthPolicy, error) { policy := models.AuthPolicy{} if err := json.Unmarshal([]byte(raw), &policy); err != nil { @@ -184,3 +352,71 @@ func defaultBootstrapPolicy(principal string) *models.AuthPolicy { }, } } + +func generateSecretKey(length int) (string, error) { + if length <= 0 { + length = 32 + } + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func normalizeUserStatus(raw string) string { + status := strings.ToLower(strings.TrimSpace(raw)) + if status == "" { + return "active" + } + if status != "active" && status != "disabled" { + return "" + } + return status +} + +func normalizePolicy(policy models.AuthPolicy, principal string) (models.AuthPolicy, error) { + if len(policy.Statements) == 0 { + return models.AuthPolicy{}, fmt.Errorf("%w: at least one policy statement is required", ErrInvalidUserInput) + } + + out := models.AuthPolicy{ + Principal: principal, + Statements: make([]models.AuthPolicyStatement, 0, len(policy.Statements)), + } + for _, stmt := range policy.Statements { + effect := strings.ToLower(strings.TrimSpace(stmt.Effect)) + if effect != "allow" && effect != "deny" { + return models.AuthPolicy{}, fmt.Errorf("%w: invalid policy effect %q", ErrInvalidUserInput, stmt.Effect) + } + + actions := make([]string, 0, len(stmt.Actions)) + for _, action := range stmt.Actions { + action = strings.TrimSpace(action) + if action == "" { + continue + } + actions = append(actions, action) + } + if len(actions) == 0 { + return models.AuthPolicy{}, fmt.Errorf("%w: policy statement must include at least one action", ErrInvalidUserInput) + } + + bucket := strings.TrimSpace(stmt.Bucket) + if bucket == "" { + bucket = "*" + } + prefix := strings.TrimSpace(stmt.Prefix) + if prefix == "" { + prefix = "*" + } + + out.Statements = append(out.Statements, models.AuthPolicyStatement{ + Effect: effect, + Actions: actions, + Bucket: bucket, + Prefix: prefix, + }) + } + return out, nil +} diff --git a/metadata/metadata.go b/metadata/metadata.go index ff0c804..452f125 100644 --- a/metadata/metadata.go +++ b/metadata/metadata.go @@ -234,6 +234,53 @@ func (h *MetadataHandler) GetAuthPolicy(accessKeyID string) (*models.AuthPolicy, return policy, nil } +func (h *MetadataHandler) ListAuthIdentities(limit int, after string) ([]models.AuthIdentity, string, error) { + if limit <= 0 { + limit = 100 + } + after = strings.TrimSpace(after) + + identities := make([]models.AuthIdentity, 0, limit) + nextCursor := "" + + err := h.view(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(authIdentitiesIndex) + if bucket == nil { + return errors.New("auth identities index not found") + } + + cursor := bucket.Cursor() + var k, v []byte + if after == "" { + k, v = cursor.First() + } else { + k, v = cursor.Seek([]byte(after)) + if k != nil && string(k) == after { + k, v = cursor.Next() + } + } + + count := 0 + for ; k != nil; k, v = cursor.Next() { + if count >= limit { + nextCursor = string(k) + break + } + record := models.AuthIdentity{} + if err := json.Unmarshal(v, &record); err != nil { + return err + } + identities = append(identities, record) + count++ + } + return nil + }) + if err != nil { + return nil, "", err + } + return identities, nextCursor, nil +} + func (h *MetadataHandler) CreateBucket(bucketName string) error { if !isValidBucketName(bucketName) { return fmt.Errorf("%w: %s", ErrInvalidBucketName, bucketName)