package auth import ( "context" "crypto/sha256" "encoding/hex" "errors" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) var ErrInvalidAPIKey = errors.New("invalid api key") // APIKeyIdentity is the tenant binding resolved from a valid API key. type APIKeyIdentity struct { KeyID uuid.UUID CompanyID uuid.UUID UserID uuid.UUID MembershipRole string // active membership role for the key owner (admin|member) } // HashAPIKey returns a SHA-256 hex digest for O(1) api_keys.key_hash lookup. // Matches hashes written by dashboard key creation. Also used for invite tokens at rest. func HashAPIKey(raw string) string { sum := sha256.Sum256([]byte(raw)) return hex.EncodeToString(sum[:]) } // AuthenticateAPIKey looks up a non-revoked key by hash and updates last_used_at. // Keys owned by inactive users or without an active company membership are rejected. // MembershipRole is returned so HTTP middleware can withhold company-admin powers // when the owner is no longer an admin (keys are admin-created; scopes/expiry columns // do not exist yet — empty/full privilege remains the default for admin-owned keys). func (s *Service) AuthenticateAPIKey(ctx context.Context, raw string) (APIKeyIdentity, error) { raw = strings.TrimSpace(raw) if raw == "" { return APIKeyIdentity{}, ErrInvalidAPIKey } hash := HashAPIKey(raw) var id APIKeyIdentity var role string err := s.Pool.QueryRow(ctx, ` SELECT k.id, k.company_id, k.user_id, m.role FROM api_keys k INNER JOIN users u ON u.id = k.user_id AND u.is_active = true INNER JOIN memberships m ON m.user_id = k.user_id AND m.company_id = k.company_id AND m.status = 'active' WHERE k.key_hash = $1 AND k.revoked_at IS NULL`, hash). Scan(&id.KeyID, &id.CompanyID, &id.UserID, &role) if errors.Is(err, pgx.ErrNoRows) { return APIKeyIdentity{}, ErrInvalidAPIKey } if err != nil { return APIKeyIdentity{}, err } id.MembershipRole = NormalizeMembershipRole(role) _, _ = s.Pool.Exec(ctx, ` UPDATE api_keys SET last_used_at = now(), updated_at = now() WHERE id = $1`, id.KeyID) return id, nil }