// Package accessauth is Sparrow's adapter for pkg/access: it fixes the // secret prefixes, maps the realm to the request's tenant and the scope to a // consumer, and holds Sparrow's lifetime rules for tokens and invites. // // Everything generic (issuing, hashing, redeeming, caching) lives in // pkg/access; nothing here should be needed by another application. package accessauth import ( "context" "database/sql" "errors" "fmt" "strings" "time" "unicode" "github.com/sarathsp06/sparrow/internal/crypto" "github.com/sarathsp06/sparrow/internal/tenant" "github.com/sarathsp06/sparrow/pkg/access" "github.com/sarathsp06/sparrow/pkg/access/pgstore" ) // Secret prefixes. Distinctive so leaked secrets are easy to spot and scan for. const ( TokenPrefix = "sparrow_tk_" InvitePrefix = "sparrow_inv_" ) // MasterKeyName is how SPARROW_API_KEY appears as a principal and in created_by. const MasterKeyName = "master key" // Realm is the realm of the tenant ctx runs as (tenant.FromContext): tokens // and invites are created in it, listed and revoked from it, and accepted only // on requests for it. A self-hosted server has one tenant, the default one. func Realm(ctx context.Context) string { return tenant.FromContext(ctx).String() } // RootRealm is the realm of SPARROW_API_KEY. The master key is the server's, // not a tenant's, so it is accepted for every tenant (see middleware.Auth). func RootRealm() string { return tenant.DefaultTenantID.String() } // Lifetime rules. // - Tenant-wide tokens act exactly like SPARROW_API_KEY. They expire after // the operator's default (SPARROW_TOKEN_DEFAULT_TTL, 90 days unless // changed) unless a TTL is given, or never when explicitly asked for. // - Consumer tokens reach outside users, so they always expire. // - Invites are links in chat: short-lived by default. const ( TenantTokenDefaultTTL = 90 * 24 * time.Hour ConsumerTokenDefaultTTL = 7 * 24 * time.Hour ConsumerTokenMaxTTL = 30 * 24 * time.Hour InviteDefaultTTL = 24 * time.Hour InviteMaxTTL = 7 * 24 * time.Hour // TokenRetention is how long expired and revoked tokens stay listed // before the daily purge deletes them. TokenRetention = 7 * 24 * time.Hour ) // TokenTTL applies the lifetime rules to a requested TTL (0 = default). // neverExpires asks for a tenant-wide token without expiry; tenantDefault is // the lifetime of a tenant-wide token when neither is given (0 = never). // A returned 0 means the token never expires. func TokenTTL(consumer *string, requested time.Duration, neverExpires bool, tenantDefault time.Duration) (time.Duration, error) { if neverExpires && requested > 0 { return 0, fmt.Errorf("set either a TTL or never_expires, not both") } if consumer == nil { switch { case neverExpires: return 0, nil case requested > 0: return requested, nil default: return tenantDefault, nil } } if neverExpires { return 0, fmt.Errorf("consumer tokens always expire (at most %d days)", ConsumerTokenMaxTTL/(24*time.Hour)) } if requested == 0 { return ConsumerTokenDefaultTTL, nil } if requested > ConsumerTokenMaxTTL { return 0, fmt.Errorf("consumer tokens can live at most %d days", ConsumerTokenMaxTTL/(24*time.Hour)) } return requested, nil } // InviteTTL applies the lifetime rules to a requested invite TTL (0 = default). func InviteTTL(requested time.Duration) (time.Duration, error) { if requested == 0 { return InviteDefaultTTL, nil } if requested > InviteMaxTTL { return 0, fmt.Errorf("invites can live at most %d days", InviteMaxTTL/(24*time.Hour)) } return requested, nil } // ValidateConsumer checks a consumer name for a consumer-scoped token or // invite. Consumers are addressed as one URL path segment // (/v1/consumers/{consumer}/...), which the portal gateway builds from the // token's scope, so a scope must be a single, literal segment. The reserved // system consumer is refused: only tenant-wide credentials may reach it. func ValidateConsumer(consumer string) error { switch { case consumer == tenant.SystemConsumer: return fmt.Errorf("consumer %q is reserved for Sparrow's system events and needs a tenant-wide credential", consumer) case strings.TrimSpace(consumer) != consumer || consumer == "": return errors.New("consumer must be non-empty with no leading or trailing spaces") case len(consumer) > 255: return errors.New("consumer is longer than 255 characters") case consumer == "." || consumer == ".." || strings.ContainsAny(consumer, "/\\?#%"): return fmt.Errorf("consumer %q is not a valid name (no /, \\, ?, #, %%, or dot segments)", consumer) } for _, r := range consumer { if unicode.IsControl(r) { return errors.New("consumer must not contain control characters") } } return nil } // New builds the access service over Postgres. apiKey (SPARROW_API_KEY) is // registered as the root key when set; sealer (see Sealer) stores the secrets // of tokens with an external id and may be nil to disable them. func New(db *sql.DB, apiKey string, sealer access.SecretSealer) (*access.Service, error) { return NewWithStore(pgstore.New(db), apiKey, sealer) } // NewWithStore is New with any store (tests use memstore). func NewWithStore(store access.Store, apiKey string, sealer access.SecretSealer) (*access.Service, error) { var roots []access.RootKey if apiKey != "" { roots = []access.RootKey{{Secret: apiKey, Realm: RootRealm(), Name: MasterKeyName}} } return access.New(access.Config{Store: store, RootKeys: roots, Sealer: sealer, TokenPrefix: TokenPrefix, InvitePrefix: InvitePrefix}) } // Sealer seals the stored secrets of tokens with an external id with Sparrow's // envelope encryption (the SPARROW_ENCRYPTION_KEYS keyring), like every other // secret at rest. func Sealer(svc *crypto.Service) access.SecretSealer { return cryptoSealer{svc} } type cryptoSealer struct{ svc *crypto.Service } func (c cryptoSealer) Seal(p []byte) ([]byte, error) { return c.svc.EnvelopeEncrypt(p) } func (c cryptoSealer) Open(s []byte) ([]byte, error) { return c.svc.EnvelopeDecrypt(s) }