diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 15f10f9..01f3bd1 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -61,4 +61,4 @@ jobs: - name: Test run: go test -v ./... env: - DATABASE_URL: postgres://cryden:cryden_test@localhost:5432/cryden_test?sslmode=disable \ No newline at end of file + DATABASE_URL: postgres://cryden:cryden_test@localhost:5432/cryden_test?sslmode=disable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 855fdb1..f507c7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,4 +36,4 @@ jobs: generate_release_notes: true name: Release ${{ github.ref_name }} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index d85b34c..d2252a7 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,35 @@ engine, err := cryden.New(cryden.Config{ The engine never sends email itself — implement `notify.EmailSender` against whatever provider you use (SendGrid, SES, SMTP), and build the actual verification URL yourself; the engine only hands you a raw token, it has no idea what your app's domain or routes look like. Calling `RequestEmailChange` without these configured returns `cryden.ErrEmailChangeNotConfigured` rather than panicking. +## OAuth (Google, GitHub, or any provider) + +The engine never performs an HTTP redirect and never talks to a specific provider — that's inherently HTTP-shaped work that belongs in your API layer. By the time you call into the engine, your app has already completed the provider's redirect/callback flow and confirmed the person's identity: + +```go +engine, err := cryden.New(cryden.Config{ + // ...required fields... + OAuth: postgres.NewOAuthStore(db), // or memory.NewOAuthStore() +}) + +tokens, err := cryden.LoginWithOAuth(ctx, engine, "google", externalID, email, callerIP, userAgent) +``` + +`LoginWithOAuth` also doubles as signup — if neither an existing link nor an existing account matches, a new user is created automatically. If the email matches an existing password-based account that isn't linked yet, it returns `*auth.ErrOAuthEmailConflict` (retrievable via `errors.As`) rather than auto-linking — auto-linking on email match alone is an account-takeover vector if a provider's email verification ever has an edge case. Resolve it by having the person log in with their password first, then call: + +```go +err := cryden.LinkOAuthIdentity(ctx, engine, userID, "google", externalID, email, callerIP) +``` + +`userID` must come from an already-verified session — never trust an email alone to authorize a link. Calling either function without `Config.OAuth` set returns `cryden.ErrOAuthNotConfigured`. + +## AI-assisted admin queries (library support only) + +The `ai` subpackage provides the safety machinery for natural-language admin tooling — an allowlisted `QueryIntent` type, `validateIntent`, and `ExecuteQuery` — plus `store/postgres.SafeQueryStore`, a read-only query executor. This is a foundation for tools like `csax`'s CLI to build on, not a feature you call directly in application code. An LLM's output is treated as untrusted data to validate against a strict allowlist, never as SQL to execute — and the actual DB connection passed to `SafeQueryStore` must be opened with a read-only Postgres role, since that's the real safety boundary, not just the allowlist check. `ai.LLMProvider` ships zero implementations; bring your own (OpenAI, Anthropic, OpenRouter, a local model). + ## What's in v2 - Signup, login, logout (single device + all devices) +- OAuth login/signup (Google, GitHub, or any provider) with explicit, non-auto-linking account collision handling — see [OAuth](#oauth-google-github-or-any-provider) - JWT access tokens + rotating opaque refresh tokens with theft/reuse detection - Session listing and revocation - Change password (requires current password, revokes all other sessions) @@ -128,11 +154,13 @@ The engine never sends email itself — implement `notify.EmailSender` against w - Persistent, DB-backed account lockout after repeated failed login attempts — survives restarts, correct across multiple instances - Email verification primitives (token issue/confirm) — delivery is pluggable via the `notify.EmailSender` interface, the engine never sends email itself - Rate limiting, bcrypt password hashing, audit logging +- Pagination and system-wide read facades (`ListAll`, `Count`, `CountActive`, `SearchByType`, `GetUser`, `ListPublicSessions`) for building admin tooling on top of the engine +- `ai` subpackage — allowlisted, read-only query safety layer for AI-assisted admin tooling built on top of this engine (see [AI-assisted admin queries](#ai-assisted-admin-queries-library-support-only)) - One storage backend: Postgres (interface-based, more can be added later) ## What's not in v2 (yet) -CLI, HTTP API, and language SDKs are separate repositories that wrap this engine — this repo is the core library only. OAuth (Google/GitHub), MFA, magic links, SMS OTP, WebAuthn, SAML, and other advanced auth methods are planned for later releases. +CLI, HTTP API, and language SDKs are separate repositories that wrap this engine — this repo is the core library only. MFA, magic links, SMS OTP, WebAuthn, SAML, and other advanced auth methods are planned for later releases. ## License diff --git a/ai/execute.go b/ai/execute.go new file mode 100644 index 0000000..1f90c3b --- /dev/null +++ b/ai/execute.go @@ -0,0 +1,25 @@ +package ai + +import "context" + +// ExecuteQuery turns natural language into a validated, read-only +// result set. naturalLanguage never reaches db directly — it only +// ever reaches provider, whose output (a QueryIntent) is validated +// against the allowlist before db.RunSafeQuery is called at all. If +// validation fails, RunSafeQuery is never invoked. +func ExecuteQuery(ctx context.Context, db QueryableStore, provider LLMProvider, naturalLanguage string) (QueryResult, error) { + intent, err := provider.ParseQueryIntent(ctx, naturalLanguage) + if err != nil { + return QueryResult{}, err + } + + if intent.Limit == 0 { + intent.Limit = DefaultLimit + } + + if err := validateIntent(intent); err != nil { + return QueryResult{}, err + } + + return db.RunSafeQuery(ctx, intent) +} diff --git a/ai/execute_test.go b/ai/execute_test.go new file mode 100644 index 0000000..435c503 --- /dev/null +++ b/ai/execute_test.go @@ -0,0 +1,147 @@ +package ai + +import ( + "context" + "errors" + "testing" +) + +// fakeLLMProvider returns a fixed QueryIntent — no real model call, +// matching the design's "no real API key needed for this layer of +// testing" plan. +type fakeLLMProvider struct { + intent QueryIntent + err error +} + +func (f fakeLLMProvider) ParseQueryIntent(ctx context.Context, naturalLanguage string) (QueryIntent, error) { + return f.intent, f.err +} + +// fakeQueryableStore records whether RunSafeQuery was ever called, so +// tests can assert an unsafe intent never reaches it. +type fakeQueryableStore struct { + called bool + lastIntent QueryIntent + returnValue QueryResult + returnErr error +} + +func (f *fakeQueryableStore) RunSafeQuery(ctx context.Context, intent QueryIntent) (QueryResult, error) { + f.called = true + f.lastIntent = intent + return f.returnValue, f.returnErr +} + +func TestExecuteQuery_ValidIntentReachesStore(t *testing.T) { + provider := fakeLLMProvider{intent: QueryIntent{ + Entity: "users", + Filters: []QueryFilter{{Field: "email", Operator: "contains", Value: "example.com"}}, + }} + db := &fakeQueryableStore{returnValue: QueryResult{Columns: []string{"id", "email"}}} + + result, err := ExecuteQuery(context.Background(), db, provider, "show me users from example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !db.called { + t.Error("expected RunSafeQuery to be called for a valid intent") + } + if len(result.Columns) != 2 { + t.Errorf("expected result to pass through from the store, got %+v", result) + } + if db.lastIntent.Limit != DefaultLimit { + t.Errorf("expected zero-limit intent to be defaulted to %d, got %d", DefaultLimit, db.lastIntent.Limit) + } +} + +func TestExecuteQuery_DisallowedEntityNeverReachesStore(t *testing.T) { + // This is the actual security property: even if a model + // hallucinates or is adversarially prompted into naming a table + // outside the allowlist, RunSafeQuery must never be called. + provider := fakeLLMProvider{intent: QueryIntent{Entity: "pg_shadow"}} + db := &fakeQueryableStore{} + + _, err := ExecuteQuery(context.Background(), db, provider, "show me the password hashes") + if !errors.Is(err, ErrUnsafeQueryIntent) { + t.Fatalf("expected ErrUnsafeQueryIntent, got %v", err) + } + if db.called { + t.Error("RunSafeQuery must not be called when the intent fails validation") + } +} + +func TestExecuteQuery_DisallowedFieldNeverReachesStore(t *testing.T) { + provider := fakeLLMProvider{intent: QueryIntent{ + Entity: "users", + Filters: []QueryFilter{{Field: "password_hash", Operator: "=", Value: "x"}}, + }} + db := &fakeQueryableStore{} + + _, err := ExecuteQuery(context.Background(), db, provider, "find users with this password hash") + if !errors.Is(err, ErrUnsafeQueryIntent) { + t.Fatalf("expected ErrUnsafeQueryIntent, got %v", err) + } + if db.called { + t.Error("RunSafeQuery must not be called when a filter field isn't allowlisted") + } +} + +func TestExecuteQuery_DisallowedOperatorNeverReachesStore(t *testing.T) { + provider := fakeLLMProvider{intent: QueryIntent{ + Entity: "sessions", + Filters: []QueryFilter{{Field: "ip", Operator: "DROP TABLE", Value: "x"}}, + }} + db := &fakeQueryableStore{} + + _, err := ExecuteQuery(context.Background(), db, provider, "malicious input") + if !errors.Is(err, ErrUnsafeQueryIntent) { + t.Fatalf("expected ErrUnsafeQueryIntent, got %v", err) + } + if db.called { + t.Error("RunSafeQuery must not be called when an operator isn't allowlisted") + } +} + +func TestExecuteQuery_GroupByMustBeAllowlistedField(t *testing.T) { + provider := fakeLLMProvider{intent: QueryIntent{ + Entity: "audit_events", + Aggregate: "group_by", + GroupBy: "metadata", // not in AllowedFields["audit_events"] + }} + db := &fakeQueryableStore{} + + _, err := ExecuteQuery(context.Background(), db, provider, "group audit events by metadata") + if !errors.Is(err, ErrUnsafeQueryIntent) { + t.Fatalf("expected ErrUnsafeQueryIntent, got %v", err) + } + if db.called { + t.Error("RunSafeQuery must not be called for an unallowlisted group_by field") + } +} + +func TestExecuteQuery_LimitOverMaxIsRejected(t *testing.T) { + provider := fakeLLMProvider{intent: QueryIntent{Entity: "users", Limit: MaxLimit + 1}} + db := &fakeQueryableStore{} + + _, err := ExecuteQuery(context.Background(), db, provider, "show me everyone") + if !errors.Is(err, ErrUnsafeQueryIntent) { + t.Fatalf("expected ErrUnsafeQueryIntent, got %v", err) + } + if db.called { + t.Error("RunSafeQuery must not be called when the limit exceeds MaxLimit") + } +} + +func TestExecuteQuery_ProviderErrorNeverReachesStore(t *testing.T) { + provider := fakeLLMProvider{err: errors.New("provider timeout")} + db := &fakeQueryableStore{} + + _, err := ExecuteQuery(context.Background(), db, provider, "anything") + if err == nil { + t.Fatal("expected the provider's error to propagate") + } + if db.called { + t.Error("RunSafeQuery must not be called if the provider itself failed") + } +} diff --git a/ai/types.go b/ai/types.go new file mode 100644 index 0000000..1a83c27 --- /dev/null +++ b/ai/types.go @@ -0,0 +1,68 @@ +// Package ai holds the pure, reusable logic behind csax's AI-assisted +// admin features. It never talks to an LLM provider or a database +// itself — it defines the shapes and the validation that make it safe +// for something else to do so. csax owns the actual CLI commands, +// prompts, and provider wiring; this package exists so that logic is +// testable without any of that. +// +// The one rule everything here exists to enforce: an LLM's output is +// untrusted data to validate, never code to execute. Nothing in this +// package lets a model produce a raw query string that reaches a +// database — only a strictly-typed, allowlisted QueryIntent that gets +// checked before it's ever turned into a real query. +package ai + +import "context" + +// LLMProvider translates natural language into a QueryIntent. Ships +// zero implementations here — the consumer (csax) brings its own +// provider and API key, the same pattern as notify.EmailSender and +// logger.Logger. This package never makes an outbound call to any AI +// provider itself. +type LLMProvider interface { + ParseQueryIntent(ctx context.Context, naturalLanguage string) (QueryIntent, error) +} + +// QueryIntent is a strictly-typed, allowlisted representation of a +// natural-language admin query. Every field is checked against an +// allowlist in validateIntent before ExecuteQuery ever builds a real +// query from it — a hallucinating or adversarially-prompted model can +// produce an intent that fails validation, but can never produce +// arbitrary executable SQL. +type QueryIntent struct { + // Entity is the thing being queried. Must be one of AllowedEntities. + Entity string + // Filters narrow the result set. Every Field and Operator must be + // allowlisted for Entity (see AllowedFields, AllowedOperators). + Filters []QueryFilter + // Aggregate is "", "count", or "group_by". + Aggregate string + // GroupBy is the column to group by when Aggregate == "group_by". + // Must be an allowlisted field for Entity. + GroupBy string + // Limit caps the number of rows returned. Zero means the caller's + // default applies (see DefaultLimit / MaxLimit in validate.go). + Limit int +} + +// QueryFilter is one condition within a QueryIntent. +type QueryFilter struct { + Field string + Operator string + Value string +} + +// QueryResult is what a validated QueryIntent resolves to. +type QueryResult struct { + Columns []string + Rows [][]string +} + +// QueryableStore executes an already-validated QueryIntent. The only +// production implementation (store/postgres) MUST use a read-only +// Postgres role for this connection — that's a real credential-level +// guarantee, not just a promise made in code, so a bug in validation +// still can't cause a write. +type QueryableStore interface { + RunSafeQuery(ctx context.Context, intent QueryIntent) (QueryResult, error) +} diff --git a/ai/validate.go b/ai/validate.go new file mode 100644 index 0000000..2c99fc5 --- /dev/null +++ b/ai/validate.go @@ -0,0 +1,117 @@ +package ai + +import ( + "errors" + "fmt" +) + +// ErrUnsafeQueryIntent is returned when a QueryIntent fails allowlist +// validation. Deliberately generic — never echoes back the specific +// bad value in a way a caller might be tempted to surface directly to +// an end user or reuse to build a query some other way. +var ErrUnsafeQueryIntent = errors.New("ai: query intent failed allowlist validation") + +// DefaultLimit and MaxLimit bound how much a single AI-driven query +// can return, regardless of what the model or the caller asked for. +const ( + DefaultLimit = 50 + MaxLimit = 500 +) + +// AllowedEntities are the only tables an AI-driven query may touch. +var AllowedEntities = map[string]bool{ + "users": true, + "sessions": true, + "audit_events": true, +} + +// AllowedOperators are the only comparison operators a filter may use. +var AllowedOperators = map[string]bool{ + "=": true, + ">": true, + "<": true, + "contains": true, +} + +// AllowedFields lists, per entity, the columns an AI-driven query may +// filter, group by, or return. PasswordHash and TokenHash are +// deliberately absent from every list below — those must never be +// queryable or returnable through this path, allowlist violation or +// not. +var AllowedFields = map[string]map[string]bool{ + "users": { + "id": true, + "email": true, + "failed_attempts": true, + "locked_until": true, + "created_at": true, + }, + "sessions": { + "id": true, + "user_id": true, + "ip": true, + "user_agent": true, + "created_at": true, + "revoked_at": true, + }, + "audit_events": { + "id": true, + "type": true, + "user_id": true, + "ip": true, + "created_at": true, + }, +} + +// EntityColumns gives a deterministic, ordered column list per +// entity — the same set as AllowedFields, just ordered, since a Go +// map has no defined iteration order and RunSafeQuery needs to build +// a stable SELECT column list. Keep these two in sync; a test asserts +// they match. +var EntityColumns = map[string][]string{ + "users": {"id", "email", "failed_attempts", "locked_until", "created_at"}, + "sessions": {"id", "user_id", "ip", "user_agent", "created_at", "revoked_at"}, + "audit_events": {"id", "type", "user_id", "ip", "created_at"}, +} + +var allowedAggregates = map[string]bool{ + "": true, + "count": true, + "group_by": true, +} + +// validateIntent is the safety gate: every field of intent must be +// allowlisted before ExecuteQuery is permitted to build a real query +// from it. Fails closed — anything not explicitly recognized is +// rejected, not passed through. +func validateIntent(intent QueryIntent) error { + if !AllowedEntities[intent.Entity] { + return fmt.Errorf("%w: entity %q not allowed", ErrUnsafeQueryIntent, intent.Entity) + } + fields := AllowedFields[intent.Entity] + + if !allowedAggregates[intent.Aggregate] { + return fmt.Errorf("%w: aggregate %q not allowed", ErrUnsafeQueryIntent, intent.Aggregate) + } + + if intent.Aggregate == "group_by" { + if intent.GroupBy == "" || !fields[intent.GroupBy] { + return fmt.Errorf("%w: group_by field %q not allowed for entity %q", ErrUnsafeQueryIntent, intent.GroupBy, intent.Entity) + } + } + + for _, f := range intent.Filters { + if !fields[f.Field] { + return fmt.Errorf("%w: filter field %q not allowed for entity %q", ErrUnsafeQueryIntent, f.Field, intent.Entity) + } + if !AllowedOperators[f.Operator] { + return fmt.Errorf("%w: operator %q not allowed", ErrUnsafeQueryIntent, f.Operator) + } + } + + if intent.Limit < 0 || intent.Limit > MaxLimit { + return fmt.Errorf("%w: limit %d out of range (max %d)", ErrUnsafeQueryIntent, intent.Limit, MaxLimit) + } + + return nil +} diff --git a/ai/validate_test.go b/ai/validate_test.go new file mode 100644 index 0000000..12c8600 --- /dev/null +++ b/ai/validate_test.go @@ -0,0 +1,40 @@ +package ai + +import "testing" + +func TestEntityColumnsMatchesAllowedFields(t *testing.T) { + // EntityColumns and AllowedFields must describe exactly the same + // set of fields per entity. If they ever drift apart, either a + // field becomes selectable without being allowlisted, or an + // allowlisted field silently stops being returned — both are + // bugs worth catching immediately, not at query time. + for entity, fields := range AllowedFields { + cols, ok := EntityColumns[entity] + if !ok { + t.Errorf("entity %q has AllowedFields but no EntityColumns", entity) + continue + } + colSet := map[string]bool{} + for _, c := range cols { + colSet[c] = true + } + if len(colSet) != len(cols) { + t.Errorf("entity %q has duplicate columns in EntityColumns: %v", entity, cols) + } + for f := range fields { + if !colSet[f] { + t.Errorf("entity %q: field %q is in AllowedFields but missing from EntityColumns", entity, f) + } + } + for c := range colSet { + if !fields[c] { + t.Errorf("entity %q: column %q is in EntityColumns but missing from AllowedFields", entity, c) + } + } + } + for entity := range EntityColumns { + if _, ok := AllowedFields[entity]; !ok { + t.Errorf("entity %q has EntityColumns but no AllowedFields", entity) + } + } +} diff --git a/auth/errors.go b/auth/errors.go index fcc5776..f5a8db8 100644 --- a/auth/errors.go +++ b/auth/errors.go @@ -17,3 +17,30 @@ var ( // That's an accepted tradeoff of lockout messaging in general. ErrAccountLocked = errors.New("auth: account temporarily locked due to failed login attempts") ) + +// ErrOAuthEmailConflict is returned by LoginWithOAuth when the +// external identity's email matches an existing password-based +// account that isn't yet linked to this provider. Deliberately a +// struct type (not a plain sentinel) so Email and Provider survive +// being wrapped as this error travels up through api's HTTP layer — +// api needs both to render a useful "log in with password, then link +// Google" message. Callers should use errors.As to retrieve it. +// +// This is the deliberate choice: auto-linking on email match alone +// was rejected as an account-takeover vector, so this error exists to +// force the user through an explicit, confirmed linking step instead. +type ErrOAuthEmailConflict struct { + Email string + Provider string +} + +func (e *ErrOAuthEmailConflict) Error() string { + return "auth: an account with this email already exists; log in with your password to link " + e.Provider +} + +// ErrOAuthIdentityAlreadyLinked is returned by LinkOAuthIdentity when +// the external identity is already linked to a DIFFERENT user than +// the one requesting the link. Never silently re-point an existing +// link to a new account — that would let one user hijack a provider +// identity another user already claimed. +var ErrOAuthIdentityAlreadyLinked = errors.New("auth: this provider account is already linked to a different user") diff --git a/auth/oauth.go b/auth/oauth.go new file mode 100644 index 0000000..f3ae201 --- /dev/null +++ b/auth/oauth.go @@ -0,0 +1,211 @@ +package auth + +import ( + "context" + "errors" + + "github.com/crydensync/cryden/v2/logger" + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/token" +) + +// LoginWithOAuth is called by api AFTER it has already completed the +// provider's redirect/callback flow and confirmed the person's +// identity. The engine never talks to Google/GitHub itself, and never +// performs an HTTP redirect — by the time this is called, the OAuth +// dance is already over. provider is a plain string ("google", +// "github"); externalID is the provider's own stable user ID, never +// its email. +// +// Three outcomes: +// 1. An OAuthIdentity already exists for (provider, externalID) -> +// issue a session for its linked user. +// 2. No existing link, but a password-based account already exists +// with this email -> return *ErrOAuthEmailConflict. Auto-linking +// here was deliberately rejected as an account-takeover vector; +// the caller must complete an explicit, confirmed linking step +// (e.g. logging in with the password account first) before a +// link is created. +// 3. Neither -> create a new User and OAuthIdentity, then issue a +// session, same as a fresh signup. +func LoginWithOAuth( + ctx context.Context, + users store.UserStore, + oauth store.OAuthStore, + sessions store.SessionStore, + ids security.IDGenerator, + refreshGen token.TokenGenerator, + jwtIssuer *token.JWTIssuer, + audit store.AuditStore, + log logger.Logger, + provider string, + externalID string, + email string, + callerIP string, + userAgent string, +) (Tokens, error) { + identity, err := oauth.GetByProviderID(ctx, provider, externalID) + switch { + case err == nil: + // Existing link — fall through to session issuance below. + case errors.Is(err, store.ErrNotFound): + user, existsErr := users.GetByEmail(ctx, email) + if existsErr == nil { + // A password-based account already owns this email, and + // it isn't linked to this provider yet. Refuse to + // auto-link; the caller must resolve this explicitly. + log.Warn("oauth: email conflict with existing account", map[string]string{"provider": provider, "user_id": user.ID}) + return Tokens{}, &ErrOAuthEmailConflict{Email: email, Provider: provider} + } + if !errors.Is(existsErr, store.ErrNotFound) { + return Tokens{}, existsErr + } + + // Neither an existing link nor an existing account — create both. + newUserID, idErr := ids.New() + if idErr != nil { + return Tokens{}, idErr + } + newUser := store.User{ID: newUserID, Email: email} + if createErr := users.Create(ctx, newUser); createErr != nil { + return Tokens{}, createErr + } + + identityID, idErr := ids.New() + if idErr != nil { + return Tokens{}, idErr + } + identity = store.OAuthIdentity{ + ID: identityID, + UserID: newUserID, + Provider: provider, + ExternalID: externalID, + Email: email, + } + if linkErr := oauth.Link(ctx, identity); linkErr != nil { + return Tokens{}, linkErr + } + + if auditErr := audit.Record(ctx, store.AuditEvent{ + Type: store.EventOAuthLinked, + UserID: newUserID, + IP: callerIP, + Metadata: map[string]string{"provider": provider}, + }); auditErr != nil { + log.Error("oauth: audit record failed", map[string]string{"error": auditErr.Error(), "user_id": newUserID}) + } + default: + return Tokens{}, err + } + + sessionID, err := ids.New() + if err != nil { + return Tokens{}, err + } + + rawRefresh, err := refreshGen.New() + if err != nil { + return Tokens{}, err + } + + session := store.Session{ + ID: sessionID, + FamilyID: sessionID, + UserID: identity.UserID, + TokenHash: token.HashToken(rawRefresh), + IP: callerIP, + UserAgent: userAgent, + } + if err := sessions.Create(ctx, session); err != nil { + return Tokens{}, err + } + + accessToken, err := jwtIssuer.Issue(identity.UserID) + if err != nil { + return Tokens{}, err + } + + if err := audit.Record(ctx, store.AuditEvent{ + Type: store.EventLoginSuccess, + UserID: identity.UserID, + IP: callerIP, + Metadata: map[string]string{"provider": provider}, + }); err != nil { + log.Error("oauth: audit record failed", map[string]string{"error": err.Error(), "user_id": identity.UserID}) + } + + log.Info("oauth: login completed", map[string]string{"user_id": identity.UserID, "provider": provider}) + + return Tokens{AccessToken: accessToken, RefreshToken: rawRefresh}, nil +} + +// LinkOAuthIdentity attaches a confirmed external identity to an +// already-authenticated user. This is the resolution path for +// *ErrOAuthEmailConflict: api should require the caller to be +// currently logged in (e.g. via password) before calling this, so +// userID comes from a verified session/access token — never from the +// OAuth callback's email alone. +// +// Idempotent if the identity is already linked to this same user. +// Returns ErrOAuthIdentityAlreadyLinked if it's linked to a +// DIFFERENT user — never re-points an existing link, since that +// would let one account steal a provider identity another account +// already claimed. +func LinkOAuthIdentity( + ctx context.Context, + users store.UserStore, + oauth store.OAuthStore, + ids security.IDGenerator, + audit store.AuditStore, + log logger.Logger, + userID string, + provider string, + externalID string, + email string, + callerIP string, +) error { + if _, err := users.GetByID(ctx, userID); err != nil { + return err + } + + existing, err := oauth.GetByProviderID(ctx, provider, externalID) + switch { + case err == nil: + if existing.UserID == userID { + // Already linked to this same user — nothing to do. + return nil + } + log.Warn("oauth: link rejected, identity already claimed", map[string]string{"provider": provider, "requesting_user_id": userID}) + return ErrOAuthIdentityAlreadyLinked + case !errors.Is(err, store.ErrNotFound): + return err + } + + identityID, err := ids.New() + if err != nil { + return err + } + + if err := oauth.Link(ctx, store.OAuthIdentity{ + ID: identityID, + UserID: userID, + Provider: provider, + ExternalID: externalID, + Email: email, + }); err != nil { + return err + } + + if auditErr := audit.Record(ctx, store.AuditEvent{ + Type: store.EventOAuthLinked, + UserID: userID, + IP: callerIP, + Metadata: map[string]string{"provider": provider}, + }); auditErr != nil { + log.Error("oauth: audit record failed", map[string]string{"error": auditErr.Error(), "user_id": userID}) + } + + log.Info("oauth: identity linked", map[string]string{"user_id": userID, "provider": provider}) + return nil +} diff --git a/auth/oauth_test.go b/auth/oauth_test.go new file mode 100644 index 0000000..4772435 --- /dev/null +++ b/auth/oauth_test.go @@ -0,0 +1,169 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/crydensync/cryden/v2/token" +) + +func newOAuthTestDeps(t *testing.T) (*memory.UserStore, *memory.OAuthStore, *memory.SessionStore, *memory.AuditStore, security.IDGenerator, token.TokenGenerator, *token.JWTIssuer) { + t.Helper() + users := memory.NewUserStore() + oauth := memory.NewOAuthStore() + sessions := memory.NewSessionStore() + audit := memory.NewAuditStore() + ids := security.NewUUIDv7Generator() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + return users, oauth, sessions, audit, ids, refreshGen, jwtIssuer +} + +func TestLoginWithOAuth_NewIdentityCreatesUserAndSession(t *testing.T) { + users, oauth, sessions, audit, ids, refreshGen, jwtIssuer := newOAuthTestDeps(t) + log := testLogger{} + ctx := context.Background() + + tokens, err := LoginWithOAuth(ctx, users, oauth, sessions, ids, refreshGen, jwtIssuer, audit, log, + "google", "google-ext-id-1", "proguy@example.com", "1.2.3.4", "test-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tokens.AccessToken == "" || tokens.RefreshToken == "" { + t.Error("expected both tokens to be populated") + } + + user, err := users.GetByEmail(ctx, "proguy@example.com") + if err != nil { + t.Fatalf("expected a new user to be created: %v", err) + } + + identity, err := oauth.GetByProviderID(ctx, "google", "google-ext-id-1") + if err != nil { + t.Fatalf("expected an oauth identity to be linked: %v", err) + } + if identity.UserID != user.ID { + t.Errorf("expected identity.UserID %q to match new user %q", identity.UserID, user.ID) + } +} + +func TestLoginWithOAuth_ExistingLinkIssuesSession(t *testing.T) { + users, oauth, sessions, audit, ids, refreshGen, jwtIssuer := newOAuthTestDeps(t) + log := testLogger{} + ctx := context.Background() + + users.Create(ctx, storeUser("user-1", "devray@example.com", "")) + oauth.Link(ctx, store.OAuthIdentity{ + ID: "identity-1", UserID: "user-1", Provider: "github", ExternalID: "gh-ext-id-1", Email: "devray@example.com", + }) + + tokens, err := LoginWithOAuth(ctx, users, oauth, sessions, ids, refreshGen, jwtIssuer, audit, log, + "github", "gh-ext-id-1", "devray@example.com", "1.2.3.4", "test-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tokens.AccessToken == "" || tokens.RefreshToken == "" { + t.Error("expected both tokens to be populated") + } + + // No second identity or duplicate user should have been created. + all, _ := oauth.ListByUser(ctx, "user-1") + if len(all) != 1 { + t.Errorf("expected exactly 1 linked identity, got %d", len(all)) + } +} + +func TestLoginWithOAuth_EmailConflictWithPasswordAccountIsRejected(t *testing.T) { + // The core account-linking decision: an OAuth login must NOT + // auto-link to an existing password-based account on email + // match alone — that's an account-takeover vector. It must + // return *ErrOAuthEmailConflict instead, retrievable via + // errors.As, and must not create a session or a new identity. + users, oauth, sessions, audit, ids, refreshGen, jwtIssuer := newOAuthTestDeps(t) + log := testLogger{} + ctx := context.Background() + + users.Create(ctx, storeUser("user-1", "proguy@example.com", "some-password-hash")) + + _, err := LoginWithOAuth(ctx, users, oauth, sessions, ids, refreshGen, jwtIssuer, audit, log, + "google", "google-ext-id-2", "proguy@example.com", "1.2.3.4", "test-agent") + + var conflict *ErrOAuthEmailConflict + if !errors.As(err, &conflict) { + t.Fatalf("expected *ErrOAuthEmailConflict, got %v", err) + } + if conflict.Email != "proguy@example.com" || conflict.Provider != "google" { + t.Errorf("unexpected conflict fields: %+v", conflict) + } + + if _, getErr := oauth.GetByProviderID(ctx, "google", "google-ext-id-2"); getErr != store.ErrNotFound { + t.Error("no oauth identity should have been created on conflict") + } +} + +func TestLinkOAuthIdentity_NewLinkSucceeds(t *testing.T) { + users, oauth, _, audit, ids, _, _ := newOAuthTestDeps(t) + log := testLogger{} + ctx := context.Background() + + users.Create(ctx, storeUser("user-1", "proguy@example.com", "some-password-hash")) + + err := LinkOAuthIdentity(ctx, users, oauth, ids, audit, log, "user-1", "google", "google-ext-id-1", "proguy@example.com", "1.2.3.4") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + identity, err := oauth.GetByProviderID(ctx, "google", "google-ext-id-1") + if err != nil { + t.Fatalf("expected identity to be linked: %v", err) + } + if identity.UserID != "user-1" { + t.Errorf("expected identity linked to user-1, got %q", identity.UserID) + } +} + +func TestLinkOAuthIdentity_AlreadyLinkedToSameUserIsIdempotent(t *testing.T) { + users, oauth, _, audit, ids, _, _ := newOAuthTestDeps(t) + log := testLogger{} + ctx := context.Background() + + users.Create(ctx, storeUser("user-1", "proguy@example.com", "some-password-hash")) + oauth.Link(ctx, store.OAuthIdentity{ + ID: "identity-1", UserID: "user-1", Provider: "google", ExternalID: "google-ext-id-1", Email: "proguy@example.com", + }) + + err := LinkOAuthIdentity(ctx, users, oauth, ids, audit, log, "user-1", "google", "google-ext-id-1", "proguy@example.com", "1.2.3.4") + if err != nil { + t.Errorf("expected no error re-linking the same user to the same identity, got %v", err) + } +} + +func TestLinkOAuthIdentity_ClaimedByDifferentUserIsRejected(t *testing.T) { + // The other half of the account-takeover protection: even an + // authenticated user must not be able to steal a provider + // identity that's already linked to someone else's account. + users, oauth, _, audit, ids, _, _ := newOAuthTestDeps(t) + log := testLogger{} + ctx := context.Background() + + users.Create(ctx, storeUser("user-1", "victim@example.com", "hash-1")) + users.Create(ctx, storeUser("user-2", "attacker@example.com", "hash-2")) + oauth.Link(ctx, store.OAuthIdentity{ + ID: "identity-1", UserID: "user-1", Provider: "google", ExternalID: "google-ext-id-1", Email: "victim@example.com", + }) + + err := LinkOAuthIdentity(ctx, users, oauth, ids, audit, log, "user-2", "google", "google-ext-id-1", "attacker@example.com", "1.2.3.4") + if err != ErrOAuthIdentityAlreadyLinked { + t.Errorf("expected ErrOAuthIdentityAlreadyLinked, got %v", err) + } + + identity, _ := oauth.GetByProviderID(ctx, "google", "google-ext-id-1") + if identity.UserID != "user-1" { + t.Errorf("identity must remain linked to original owner user-1, got %q", identity.UserID) + } +} diff --git a/cmd/smoketest/main.go b/cmd/smoketest/main.go index 21a85e5..fc3a4fd 100644 --- a/cmd/smoketest/main.go +++ b/cmd/smoketest/main.go @@ -2,10 +2,13 @@ package main import ( "context" + "errors" "fmt" "os" "github.com/crydensync/cryden/v2" + "github.com/crydensync/cryden/v2/ai" + "github.com/crydensync/cryden/v2/auth" "github.com/crydensync/cryden/v2/store/memory" ) @@ -25,6 +28,7 @@ func main() { Users: memory.NewUserStore(), Sessions: memory.NewSessionStore(), Audit: memory.NewAuditStore(), + OAuth: memory.NewOAuthStore(), }) check("engine construction", err) @@ -118,5 +122,86 @@ func main() { _ = tokens2 + // --- OAuth: fresh signup via provider --- + oauthTokens, err := cryden.LoginWithOAuth(ctx, engine, "google", "google-ext-1", "devray@example.com", "1.2.3.4", "test-agent") + check("oauth login (new user)", err) + if oauthTokens.AccessToken == "" { + fmt.Println("FAIL oauth login: expected an access token") + os.Exit(1) + } + + // --- OAuth: same identity logs in again, no duplicate user/identity --- + _, err = cryden.LoginWithOAuth(ctx, engine, "google", "google-ext-1", "devray@example.com", "1.2.3.4", "test-agent") + check("oauth login (existing link)", err) + + // --- OAuth: email collision with an existing password account is rejected, not auto-linked --- + _, err = cryden.LoginWithOAuth(ctx, engine, "github", "gh-ext-1", "proguy@example.com", "1.2.3.4", "test-agent") + var conflict *auth.ErrOAuthEmailConflict + if !errors.As(err, &conflict) { + fmt.Printf("FAIL oauth email conflict: expected *auth.ErrOAuthEmailConflict, got %v\n", err) + os.Exit(1) + } + fmt.Println("OK oauth login correctly rejected email conflict instead of auto-linking") + + // --- OAuth: resolve that conflict by linking while authenticated as the password account --- + err = cryden.LinkOAuthIdentity(ctx, engine, user.ID, "github", "gh-ext-1", "proguy@example.com", "1.2.3.4") + check("link oauth identity", err) + + // --- OAuth: a different user cannot steal an identity already linked elsewhere --- + attacker, err := cryden.SignUp(ctx, engine, "attacker@example.com", "Pass@2026", "1.2.3.4") + check("signup second user for hijack test", err) + err = cryden.LinkOAuthIdentity(ctx, engine, attacker.ID, "github", "gh-ext-1", "attacker@example.com", "1.2.3.4") + if !errors.Is(err, auth.ErrOAuthIdentityAlreadyLinked) { + fmt.Printf("FAIL oauth link hijack: expected ErrOAuthIdentityAlreadyLinked, got %v\n", err) + os.Exit(1) + } + fmt.Println("OK oauth link correctly rejected a claim on an already-linked identity") + + // --- AI: an unsafe intent must never reach the query store --- + unsafeStore := &recordingQueryStore{} + _, err = ai.ExecuteQuery(ctx, unsafeStore, fixedIntentProvider{intent: ai.QueryIntent{Entity: "pg_shadow"}}, "show me password hashes") + if !errors.Is(err, ai.ErrUnsafeQueryIntent) { + fmt.Printf("FAIL ai unsafe intent: expected ErrUnsafeQueryIntent, got %v\n", err) + os.Exit(1) + } + if unsafeStore.called { + fmt.Println("FAIL ai unsafe intent: query store must not be called for a disallowed entity") + os.Exit(1) + } + fmt.Println("OK ai.ExecuteQuery correctly blocked a disallowed entity before reaching the store") + + // --- AI: a valid, allowlisted intent reaches the store normally --- + safeStore := &recordingQueryStore{} + _, err = ai.ExecuteQuery(ctx, safeStore, fixedIntentProvider{intent: ai.QueryIntent{Entity: "users"}}, "show me users") + check("ai valid intent reaches store", err) + if !safeStore.called { + fmt.Println("FAIL ai valid intent: expected the query store to be called") + os.Exit(1) + } + fmt.Println("OK ai.ExecuteQuery correctly passed an allowlisted intent through") + fmt.Println("\nALL CHECKS PASSED") } + +// fixedIntentProvider is a minimal ai.LLMProvider for the smoke test — +// no real model call, just returns whatever intent was configured. +type fixedIntentProvider struct { + intent ai.QueryIntent +} + +func (p fixedIntentProvider) ParseQueryIntent(ctx context.Context, naturalLanguage string) (ai.QueryIntent, error) { + return p.intent, nil +} + +// recordingQueryStore is a minimal ai.QueryableStore that just +// records whether it was ever called — enough to prove validation +// actually gates the call, not just that ExecuteQuery returns an +// error. +type recordingQueryStore struct { + called bool +} + +func (s *recordingQueryStore) RunSafeQuery(ctx context.Context, intent ai.QueryIntent) (ai.QueryResult, error) { + s.called = true + return ai.QueryResult{}, nil +} diff --git a/config.go b/config.go index a2a7dbe..a1a7482 100644 --- a/config.go +++ b/config.go @@ -26,6 +26,9 @@ type Config struct { // rather than a nil-pointer panic. Verifications store.VerificationStore EmailSender notify.EmailSender + // OAuth is optional — only required if LoginWithOAuth is used. + // Left unset, LoginWithOAuth returns ErrOAuthNotConfigured. + OAuth store.OAuthStore // Optional — sensible defaults applied in New() if zero-valued. // These are tuning knobs, not security-critical secrets, so diff --git a/cryden.go b/cryden.go index 07789f0..b5fae0c 100644 --- a/cryden.go +++ b/cryden.go @@ -64,6 +64,36 @@ func ConfirmEmailChange(ctx context.Context, e *Engine, rawToken string) error { return auth.ConfirmEmailChange(ctx, e.users, e.verifications, e.audit, e.log, rawToken) } +// ErrOAuthNotConfigured is returned by LoginWithOAuth if the Engine +// was built without Config.OAuth set. +var ErrOAuthNotConfigured = errors.New("cryden: oauth login requires Config.OAuth to be set") + +// LoginWithOAuth is called after api has already completed the +// provider's redirect/callback flow and confirmed the person's +// identity — the engine itself never talks to Google/GitHub or +// performs an HTTP redirect. Returns *auth.ErrOAuthEmailConflict +// (retrievable via errors.As) if externalID's email matches an +// existing password-based account that isn't linked yet; the engine +// deliberately does not auto-link in that case. +func LoginWithOAuth(ctx context.Context, e *Engine, provider, externalID, email, callerIP, userAgent string) (Tokens, error) { + if e.oauth == nil { + return Tokens{}, ErrOAuthNotConfigured + } + return auth.LoginWithOAuth(ctx, e.users, e.oauth, e.sessions, e.ids, e.refreshGen, e.jwtIssuer, e.audit, e.log, provider, externalID, email, callerIP, userAgent) +} + +// LinkOAuthIdentity attaches a confirmed external identity to an +// already-authenticated user. userID must come from a verified +// session/access token — this is the resolution path api should use +// after a *auth.ErrOAuthEmailConflict, once the caller has logged in +// with their password to prove ownership of the account. +func LinkOAuthIdentity(ctx context.Context, e *Engine, userID, provider, externalID, email, callerIP string) error { + if e.oauth == nil { + return ErrOAuthNotConfigured + } + return auth.LinkOAuthIdentity(ctx, e.users, e.oauth, e.ids, e.audit, e.log, userID, provider, externalID, email, callerIP) +} + // Logout revokes a single session. Verifies ownership before revoking. func Logout(ctx context.Context, e *Engine, sessionID, userID string) error { return auth.Logout(ctx, e.sessions, e.audit, e.log, sessionID, userID) @@ -118,6 +148,33 @@ func ListSessions(ctx context.Context, e *Engine, userID string) ([]store.Sessio return session.List(ctx, e.sessions, userID) } +// GetUser looks up a user by email. Read-only, no side effects — safe +// to expose as a public facade function, unlike ChangePassword/ +// DeleteAccount which require self-authentication. Added because +// admin tooling had no way to do this except reaching past the public +// facade into the store layer directly. +func GetUser(ctx context.Context, e *Engine, email string) (store.User, error) { + return e.users.GetByEmail(ctx, email) +} + +// ListPublicSessions is a redacted alternative to ListSessions, +// returning store.PublicSession (no TokenHash/FamilyID) instead of +// the full store.Session. Added alongside ListSessions, not as a +// replacement for it — existing callers of ListSessions are +// unaffected. Consumers building an HTTP-facing endpoint should +// prefer this over ListSessions plus their own hand-rolled DTO. +func ListPublicSessions(ctx context.Context, e *Engine, userID string) ([]store.PublicSession, error) { + sessions, err := session.List(ctx, e.sessions, userID) + if err != nil { + return nil, err + } + out := make([]store.PublicSession, 0, len(sessions)) + for _, s := range sessions { + out = append(out, s.ToPublic()) + } + return out, nil +} + // RevokeSession revokes a specific session. Verifies ownership before // revoking. func RevokeSession(ctx context.Context, e *Engine, sessionID, userID string) error { diff --git a/engine.go b/engine.go index ec0686e..f740d91 100644 --- a/engine.go +++ b/engine.go @@ -19,6 +19,7 @@ type Engine struct { audit store.AuditStore verifications store.VerificationStore emailSender notify.EmailSender + oauth store.OAuthStore hasher security.Hasher ids security.IDGenerator @@ -60,6 +61,7 @@ func New(cfg Config) (*Engine, error) { audit: cfg.Audit, verifications: cfg.Verifications, emailSender: cfg.EmailSender, + oauth: cfg.OAuth, hasher: hasher, ids: security.NewUUIDv7Generator(), rateLimiter: security.NewInMemoryRateLimiter(cfg.RateLimitAttempts, cfg.RateLimitWindow), diff --git a/new_facade_test.go b/new_facade_test.go new file mode 100644 index 0000000..66b4e31 --- /dev/null +++ b/new_facade_test.go @@ -0,0 +1,136 @@ +package cryden + +import ( + "context" + "testing" + + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/store/memory" +) + +func TestGetUser_Success(t *testing.T) { + cfg := validConfig() + engine, _ := New(cfg) + ctx := context.Background() + + _, err := SignUp(ctx, engine, "devray@example.com", "Pass@2026", "1.2.3.4") + if err != nil { + t.Fatalf("signup failed: %v", err) + } + + user, err := GetUser(ctx, engine, "devray@example.com") + if err != nil { + t.Fatalf("GetUser failed: %v", err) + } + if user.Email != "devray@example.com" { + t.Errorf("expected devray@example.com, got %s", user.Email) + } +} + +func TestGetUser_NotFound(t *testing.T) { + cfg := validConfig() + engine, _ := New(cfg) + ctx := context.Background() + + _, err := GetUser(ctx, engine, "nobody@example.com") + if err != store.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestListPublicSessions_ExcludesTokenHash(t *testing.T) { + cfg := validConfig() + engine, _ := New(cfg) + ctx := context.Background() + + SignUp(ctx, engine, "devray@example.com", "Pass@2026", "1.2.3.4") + Login(ctx, engine, "devray@example.com", "Pass@2026", "1.2.3.4", "test-agent") + + sessions, err := ListPublicSessions(ctx, engine, mustUserID(ctx, engine, t)) + if err != nil { + t.Fatalf("ListPublicSessions failed: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("expected 1 session, got %d", len(sessions)) + } + // PublicSession has no TokenHash field at all — this is a + // compile-time guarantee, not just a runtime check. If this test + // compiles, the field genuinely doesn't exist on the type. + if sessions[0].ID == "" { + t.Error("expected a populated session ID") + } +} + +func mustUserID(ctx context.Context, e *Engine, t *testing.T) string { + t.Helper() + u, err := GetUser(ctx, e, "devray@example.com") + if err != nil { + t.Fatalf("failed to look up user: %v", err) + } + return u.ID +} + +func TestStore_ListAllAndCount_Memory(t *testing.T) { + us := memory.NewUserStore() + ctx := context.Background() + + count, err := us.Count(ctx) + if err != nil || count != 0 { + t.Fatalf("expected 0 users initially, got %d (err: %v)", count, err) + } + + for i := 0; i < 3; i++ { + us.Create(ctx, store.User{ID: string(rune('a' + i)), Email: string(rune('a'+i)) + "@example.com"}) + } + + count, err = us.Count(ctx) + if err != nil || count != 3 { + t.Fatalf("expected 3 users, got %d (err: %v)", count, err) + } + + all, err := us.ListAll(ctx, 10, 0) + if err != nil || len(all) != 3 { + t.Fatalf("expected 3 users listed, got %d (err: %v)", len(all), err) + } + + paged, err := us.ListAll(ctx, 2, 0) + if err != nil || len(paged) != 2 { + t.Fatalf("expected 2 users with limit=2, got %d (err: %v)", len(paged), err) + } +} + +func TestStore_CountActive_Memory(t *testing.T) { + ss := memory.NewSessionStore() + ctx := context.Background() + + ss.Create(ctx, store.Session{ID: "s1", FamilyID: "s1", UserID: "u1"}) + ss.Create(ctx, store.Session{ID: "s2", FamilyID: "s2", UserID: "u1"}) + ss.Revoke(ctx, "s2") + + count, err := ss.CountActive(ctx) + if err != nil || count != 1 { + t.Fatalf("expected 1 active session, got %d (err: %v)", count, err) + } +} + +func TestStore_SearchByType_Memory(t *testing.T) { + as := memory.NewAuditStore() + ctx := context.Background() + + as.Record(ctx, store.AuditEvent{Type: store.EventLoginSuccess, UserID: "u1"}) + as.Record(ctx, store.AuditEvent{Type: store.EventTokenReuseDetected, UserID: "u2"}) + as.Record(ctx, store.AuditEvent{Type: store.EventTokenReuseDetected, UserID: "u3"}) + + events, err := as.SearchByType(ctx, store.EventTokenReuseDetected, 10) + if err != nil { + t.Fatalf("SearchByType failed: %v", err) + } + if len(events) != 2 { + t.Fatalf("expected 2 token_reuse_detected events across all users, got %d", len(events)) + } + for _, e := range events { + if e.Type != store.EventTokenReuseDetected { + t.Errorf("expected only token_reuse_detected events, got %s", e.Type) + } + } +} diff --git a/store/interfaces.go b/store/interfaces.go index 54ee532..278c468 100644 --- a/store/interfaces.go +++ b/store/interfaces.go @@ -36,6 +36,14 @@ type UserStore interface { // in-memory — must survive process restarts and work correctly // across multiple instances, unlike the rate limiter. LockAccount(ctx context.Context, id string, until time.Time) error + + // ListAll returns users newest-first, paginated. Added to close a + // real gap: earlier tooling (the admin CLI) needed this and had no + // way to get it except querying the schema directly. Read-only, + // no ownership semantics to enforce, safe as a store-level method. + ListAll(ctx context.Context, limit, offset int) ([]User, error) + // Count returns the total number of users. + Count(ctx context.Context) (int, error) } // Session is the domain representation of a refresh-token-backed session. @@ -70,9 +78,31 @@ type SessionStore interface { // and create calls from leaving a session family in an inconsistent // state (old token dead, new token never created). RotateToken(ctx context.Context, oldSessionID string, newSession Session) error + + // CountActive returns the total number of active (non-revoked) + // sessions, system-wide — not scoped to one user. Closes a real + // gap: admin tooling needed this and had no non-store-bypassing way + // to get it. + CountActive(ctx context.Context) (int, error) +} + +// PublicSession is a redacted view of Session, safe to return to a +// client over an API — deliberately excludes TokenHash and FamilyID. +// Added because both known consuming apps (a reference HTTP API and a +// reference frontend app) independently wrote the same stripping +// logic themselves; this gives future consumers a ready option +// instead of a third reimplementation. +type PublicSession struct { + ID string + IP string + UserAgent string + CreatedAt time.Time +} + +func (s Session) ToPublic() PublicSession { + return PublicSession{ID: s.ID, IP: s.IP, UserAgent: s.UserAgent, CreatedAt: s.CreatedAt} } -// AuditEventType identifies the kind of audit event recorded. type AuditEventType string const ( @@ -89,6 +119,7 @@ const ( EventEmailChangeRequested AuditEventType = "email_change_requested" EventEmailChanged AuditEventType = "email_changed" EventAccountDeleted AuditEventType = "account_deleted" + EventOAuthLinked AuditEventType = "oauth_linked" ) // AuditEvent is a single security-relevant, queryable record. @@ -108,6 +139,14 @@ type AuditEvent struct { type AuditStore interface { Record(ctx context.Context, event AuditEvent) error ListByUser(ctx context.Context, userID string, limit int) ([]AuditEvent, error) + + // SearchByType returns the most recent events of a given type, + // across ALL users — ListByUser only supports per-user queries. + // Closes a real gap found while building admin tooling: there was + // no way to search for a security-relevant event system-wide + // (e.g. "every token_reuse_detected event, whoever it happened to") + // without bypassing the store layer entirely. + SearchByType(ctx context.Context, eventType AuditEventType, limit int) ([]AuditEvent, error) } // VerificationPurpose distinguishes what a verification token is for — @@ -141,3 +180,26 @@ type VerificationStore interface { GetByTokenHash(ctx context.Context, tokenHash string) (VerificationToken, error) MarkUsed(ctx context.Context, id string) error } + +// OAuthIdentity links a User to an external OAuth provider account. +// Provider is a plain string ("google", "github") rather than an enum +// so a new provider never requires an engine change. ExternalID is +// the provider's own stable user ID — never the email, since a +// provider's email on file can change and isn't a guaranteed stable +// identifier the way their internal ID is. +type OAuthIdentity struct { + ID string + UserID string + Provider string + ExternalID string + Email string + CreatedAt time.Time +} + +// OAuthStore defines persistence for linked OAuth identities. +type OAuthStore interface { + Link(ctx context.Context, identity OAuthIdentity) error + GetByProviderID(ctx context.Context, provider, externalID string) (OAuthIdentity, error) + ListByUser(ctx context.Context, userID string) ([]OAuthIdentity, error) + Unlink(ctx context.Context, identityID string) error +} diff --git a/store/memory/audit_store.go b/store/memory/audit_store.go index 928cb4e..c256965 100644 --- a/store/memory/audit_store.go +++ b/store/memory/audit_store.go @@ -39,4 +39,16 @@ func (s *AuditStore) ListByUser(ctx context.Context, userID string, limit int) ( return out, nil } +func (s *AuditStore) SearchByType(ctx context.Context, eventType store.AuditEventType, limit int) ([]store.AuditEvent, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []store.AuditEvent + for i := len(s.events) - 1; i >= 0 && len(out) < limit; i-- { + if s.events[i].Type == eventType { + out = append(out, s.events[i]) + } + } + return out, nil +} + var _ store.AuditStore = (*AuditStore)(nil) diff --git a/store/memory/oauth_store.go b/store/memory/oauth_store.go new file mode 100644 index 0000000..3217788 --- /dev/null +++ b/store/memory/oauth_store.go @@ -0,0 +1,65 @@ +package memory + +import ( + "context" + "sync" + "time" + + "github.com/crydensync/cryden/v2/store" +) + +// OAuthStore is an in-memory store.OAuthStore implementation for +// tests and local experimentation only — not a supported v1 +// production backend. The Postgres implementation is authoritative +// for prod. +type OAuthStore struct { + mu sync.Mutex + byID map[string]store.OAuthIdentity +} + +func NewOAuthStore() *OAuthStore { + return &OAuthStore{byID: make(map[string]store.OAuthIdentity)} +} + +func (s *OAuthStore) Link(ctx context.Context, identity store.OAuthIdentity) error { + s.mu.Lock() + defer s.mu.Unlock() + identity.CreatedAt = time.Now() + s.byID[identity.ID] = identity + return nil +} + +func (s *OAuthStore) GetByProviderID(ctx context.Context, provider, externalID string) (store.OAuthIdentity, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, id := range s.byID { + if id.Provider == provider && id.ExternalID == externalID { + return id, nil + } + } + return store.OAuthIdentity{}, store.ErrNotFound +} + +func (s *OAuthStore) ListByUser(ctx context.Context, userID string) ([]store.OAuthIdentity, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := []store.OAuthIdentity{} + for _, id := range s.byID { + if id.UserID == userID { + out = append(out, id) + } + } + return out, nil +} + +func (s *OAuthStore) Unlink(ctx context.Context, identityID string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byID[identityID]; !ok { + return store.ErrNotFound + } + delete(s.byID, identityID) + return nil +} + +var _ store.OAuthStore = (*OAuthStore)(nil) diff --git a/store/memory/session_store.go b/store/memory/session_store.go index d0ea6e2..48317a6 100644 --- a/store/memory/session_store.go +++ b/store/memory/session_store.go @@ -120,4 +120,16 @@ func (s *SessionStore) RotateToken(ctx context.Context, oldSessionID string, new return nil } +func (s *SessionStore) CountActive(ctx context.Context) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + count := 0 + for _, sess := range s.byID { + if sess.RevokedAt == nil { + count++ + } + } + return count, nil +} + var _ store.SessionStore = (*SessionStore)(nil) diff --git a/store/memory/user_store.go b/store/memory/user_store.go index 8b6f736..cf6abff 100644 --- a/store/memory/user_store.go +++ b/store/memory/user_store.go @@ -2,6 +2,7 @@ package memory import ( "context" + "sort" "sync" "time" @@ -124,4 +125,30 @@ func (s *UserStore) LockAccount(ctx context.Context, id string, until time.Time) return nil } +func (s *UserStore) ListAll(ctx context.Context, limit, offset int) ([]store.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + + all := make([]store.User, 0, len(s.byID)) + for _, u := range s.byID { + all = append(all, u) + } + sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt.After(all[j].CreatedAt) }) + + if offset >= len(all) { + return []store.User{}, nil + } + end := offset + limit + if end > len(all) { + end = len(all) + } + return all[offset:end], nil +} + +func (s *UserStore) Count(ctx context.Context) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.byID), nil +} + var _ store.UserStore = (*UserStore)(nil) diff --git a/store/postgres/audit_store.go b/store/postgres/audit_store.go index 9219397..d73e3d8 100644 --- a/store/postgres/audit_store.go +++ b/store/postgres/audit_store.go @@ -8,7 +8,7 @@ import ( "github.com/crydensync/cryden/v2/store" ) -// AuditStore is the v2 production store.AuditStore implementation. +// AuditStore is the v1 production store.AuditStore implementation. type AuditStore struct { db *sql.DB } @@ -82,4 +82,42 @@ func (s *AuditStore) ListByUser(ctx context.Context, userID string, limit int) ( return out, rows.Err() } +func (s *AuditStore) SearchByType(ctx context.Context, eventType store.AuditEventType, limit int) ([]store.AuditEvent, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, type, user_id, ip, metadata, created_at + FROM audit_events + WHERE type = $1 + ORDER BY created_at DESC + LIMIT $2 + `, string(eventType), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []store.AuditEvent{} + for rows.Next() { + var ( + e store.AuditEvent + evType string + uid sql.NullString + metadata []byte + ) + if err := rows.Scan(&e.ID, &evType, &uid, &e.IP, &metadata, &e.CreatedAt); err != nil { + return nil, err + } + e.Type = store.AuditEventType(evType) + if uid.Valid { + e.UserID = uid.String + } + if metadata != nil { + if err := json.Unmarshal(metadata, &e.Metadata); err != nil { + return nil, err + } + } + out = append(out, e) + } + return out, rows.Err() +} + var _ store.AuditStore = (*AuditStore)(nil) diff --git a/store/postgres/migrations/0002_oauth_identities.down.sql b/store/postgres/migrations/0002_oauth_identities.down.sql new file mode 100644 index 0000000..e2eec80 --- /dev/null +++ b/store/postgres/migrations/0002_oauth_identities.down.sql @@ -0,0 +1,3 @@ +-- 0002_oauth_identities.down.sql + +DROP TABLE oauth_identities; diff --git a/store/postgres/migrations/0002_oauth_identities.up.sql b/store/postgres/migrations/0002_oauth_identities.up.sql new file mode 100644 index 0000000..c7e8539 --- /dev/null +++ b/store/postgres/migrations/0002_oauth_identities.up.sql @@ -0,0 +1,15 @@ +-- 0002_oauth_identities.up.sql + +CREATE TABLE oauth_identities ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + external_id TEXT NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Backstops GetByProviderID and is the real guard against ever + -- double-linking the same external account. + UNIQUE (provider, external_id) +); + +CREATE INDEX idx_oauth_identities_user_id ON oauth_identities(user_id); diff --git a/store/postgres/new_methods_test.go b/store/postgres/new_methods_test.go new file mode 100644 index 0000000..b05e93a --- /dev/null +++ b/store/postgres/new_methods_test.go @@ -0,0 +1,133 @@ +package postgres + +import ( + "context" + "testing" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" +) + +func TestPostgresUserStore_ListAllAndCount(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + us := NewUserStore(db) + ctx := context.Background() + + ids := security.NewUUIDv7Generator() + var created []string + for i := 0; i < 3; i++ { + id, _ := ids.New() + email := uniqueEmail(t) + if err := us.Create(ctx, store.User{ID: id, Email: email, PasswordHash: "hash"}); err != nil { + t.Fatalf("create failed: %v", err) + } + created = append(created, id) + } + defer func() { + for _, id := range created { + us.Delete(ctx, id) + } + }() + + all, err := us.ListAll(ctx, 1000, 0) + if err != nil { + t.Fatalf("ListAll failed: %v", err) + } + foundCount := 0 + for _, u := range all { + for _, id := range created { + if u.ID == id { + foundCount++ + } + } + } + if foundCount != 3 { + t.Errorf("expected all 3 created users in ListAll results, found %d", foundCount) + } + + limited, err := us.ListAll(ctx, 1, 0) + if err != nil { + t.Fatalf("ListAll with limit failed: %v", err) + } + if len(limited) != 1 { + t.Errorf("expected exactly 1 result with limit=1, got %d", len(limited)) + } + + count, err := us.Count(ctx) + if err != nil { + t.Fatalf("Count failed: %v", err) + } + if count < 3 { + t.Errorf("expected at least 3 users counted, got %d", count) + } +} + +func TestPostgresSessionStore_CountActive(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + us := NewUserStore(db) + ss := NewSessionStore(db) + ctx := context.Background() + + userID := createTestUser(t, us) + defer us.Delete(ctx, userID) + + before, err := ss.CountActive(ctx) + if err != nil { + t.Fatalf("CountActive failed: %v", err) + } + + ids := security.NewUUIDv7Generator() + s1, _ := ids.New() + s2, _ := ids.New() + ss.Create(ctx, store.Session{ID: s1, FamilyID: s1, UserID: userID, TokenHash: s1 + "-hash"}) + ss.Create(ctx, store.Session{ID: s2, FamilyID: s2, UserID: userID, TokenHash: s2 + "-hash"}) + ss.Revoke(ctx, s2) // one revoked, one still active + + after, err := ss.CountActive(ctx) + if err != nil { + t.Fatalf("CountActive failed: %v", err) + } + if after != before+1 { + t.Errorf("expected active count to increase by exactly 1 (one created active, one created then revoked), got before=%d after=%d", before, after) + } +} + +func TestPostgresAuditStore_SearchByType(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + us := NewUserStore(db) + as := NewAuditStore(db) + ctx := context.Background() + + user1 := createTestUser(t, us) + defer us.Delete(ctx, user1) + user2 := createTestUser(t, us) + defer us.Delete(ctx, user2) + + as.Record(ctx, store.AuditEvent{Type: store.EventTokenReuseDetected, UserID: user1}) + as.Record(ctx, store.AuditEvent{Type: store.EventTokenReuseDetected, UserID: user2}) + as.Record(ctx, store.AuditEvent{Type: store.EventLoginSuccess, UserID: user1}) + + events, err := as.SearchByType(ctx, store.EventTokenReuseDetected, 100) + if err != nil { + t.Fatalf("SearchByType failed: %v", err) + } + + foundUser1, foundUser2 := false, false + for _, e := range events { + if e.Type != store.EventTokenReuseDetected { + t.Errorf("expected only token_reuse_detected events, got %s", e.Type) + } + if e.UserID == user1 { + foundUser1 = true + } + if e.UserID == user2 { + foundUser2 = true + } + } + if !foundUser1 || !foundUser2 { + t.Error("expected search to find the event for BOTH users — this is the system-wide property that matters") + } +} diff --git a/store/postgres/oauth_store.go b/store/postgres/oauth_store.go new file mode 100644 index 0000000..1d824b2 --- /dev/null +++ b/store/postgres/oauth_store.go @@ -0,0 +1,76 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + + _ "github.com/lib/pq" + + "github.com/crydensync/cryden/v2/store" +) + +// OAuthStore is the v1 production store.OAuthStore implementation. +type OAuthStore struct { + db *sql.DB +} + +// NewOAuthStore wraps an existing *sql.DB. The caller owns the +// connection's lifecycle (opening, closing, pool sizing) — this +// package never opens or closes the DB itself. +func NewOAuthStore(db *sql.DB) *OAuthStore { + return &OAuthStore{db: db} +} + +func (s *OAuthStore) Link(ctx context.Context, identity store.OAuthIdentity) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO oauth_identities (id, user_id, provider, external_id, email) + VALUES ($1, $2, $3, $4, $5) + `, identity.ID, identity.UserID, identity.Provider, identity.ExternalID, identity.Email) + return err +} + +func (s *OAuthStore) GetByProviderID(ctx context.Context, provider, externalID string) (store.OAuthIdentity, error) { + var id store.OAuthIdentity + err := s.db.QueryRowContext(ctx, ` + SELECT id, user_id, provider, external_id, email, created_at + FROM oauth_identities WHERE provider = $1 AND external_id = $2 + `, provider, externalID).Scan(&id.ID, &id.UserID, &id.Provider, &id.ExternalID, &id.Email, &id.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return store.OAuthIdentity{}, store.ErrNotFound + } + return id, err +} + +func (s *OAuthStore) ListByUser(ctx context.Context, userID string) ([]store.OAuthIdentity, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, user_id, provider, external_id, email, created_at + FROM oauth_identities + WHERE user_id = $1 + ORDER BY created_at DESC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []store.OAuthIdentity{} + for rows.Next() { + var id store.OAuthIdentity + if err := rows.Scan(&id.ID, &id.UserID, &id.Provider, &id.ExternalID, &id.Email, &id.CreatedAt); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +func (s *OAuthStore) Unlink(ctx context.Context, identityID string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM oauth_identities WHERE id = $1`, identityID) + if err != nil { + return err + } + return checkRowsAffected(result) +} + +var _ store.OAuthStore = (*OAuthStore)(nil) diff --git a/store/postgres/safe_query_store.go b/store/postgres/safe_query_store.go new file mode 100644 index 0000000..932eebe --- /dev/null +++ b/store/postgres/safe_query_store.go @@ -0,0 +1,176 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "strings" + + _ "github.com/lib/pq" + + "github.com/crydensync/cryden/v2/ai" +) + +// operatorSQL maps ai's allowlisted operators to real SQL. Anything +// not in this map is a bug upstream — ExecuteQuery must never call +// RunSafeQuery with an intent that didn't pass validateIntent first. +var operatorSQL = map[string]string{ + "=": "=", + ">": ">", + "<": "<", + "contains": "ILIKE", +} + +// SafeQueryStore is the v1 production ai.QueryableStore +// implementation for csax's AI-assisted admin features. +// +// The db passed to NewSafeQueryStore MUST be opened with a read-only +// Postgres role. That is the actual safety boundary — even a bug in +// ai.validateIntent, or in the query-building below, cannot cause a +// write if the credential itself is physically incapable of one. +// Allowlist validation is defense-in-depth on top of that, not a +// substitute for it. +type SafeQueryStore struct { + db *sql.DB +} + +// NewSafeQueryStore wraps an existing *sql.DB opened with a read-only +// role. The caller owns the connection's lifecycle, same as every +// other store/postgres constructor. +func NewSafeQueryStore(db *sql.DB) *SafeQueryStore { + return &SafeQueryStore{db: db} +} + +// RunSafeQuery builds and executes a parameterized query from an +// already-validated QueryIntent. It never accepts free-form SQL and +// never string-formats a filter's Value into the query — every value +// goes through a placeholder ($1, $2, ...), same as every other store +// in this package. +// +// This function trusts that intent already passed ai's +// validateIntent (via ai.ExecuteQuery) — Entity, every Filter.Field, +// every Filter.Operator, and GroupBy are assumed allowlisted. +// Re-checking membership here anyway (rather than trusting the +// caller blindly) is what makes this store safe to call directly in +// tests without going through ExecuteQuery. +func (s *SafeQueryStore) RunSafeQuery(ctx context.Context, intent ai.QueryIntent) (ai.QueryResult, error) { + if !ai.AllowedEntities[intent.Entity] { + return ai.QueryResult{}, fmt.Errorf("postgres: entity %q not allowed", intent.Entity) + } + cols := ai.EntityColumns[intent.Entity] + + where, args, err := buildWhereClause(intent) + if err != nil { + return ai.QueryResult{}, err + } + + limit := intent.Limit + if limit <= 0 { + limit = ai.DefaultLimit + } + + switch intent.Aggregate { + case "count": + return s.runCount(ctx, intent.Entity, where, args) + case "group_by": + return s.runGroupBy(ctx, intent.Entity, intent.GroupBy, where, args) + default: + return s.runSelect(ctx, intent.Entity, cols, where, args, limit) + } +} + +func (s *SafeQueryStore) runSelect(ctx context.Context, entity string, cols []string, where string, args []any, limit int) (ai.QueryResult, error) { + query := fmt.Sprintf("SELECT %s FROM %s%s LIMIT $%d", strings.Join(cols, ", "), entity, where, len(args)+1) + rows, err := s.db.QueryContext(ctx, query, append(args, limit)...) + if err != nil { + return ai.QueryResult{}, err + } + defer rows.Close() + + result := ai.QueryResult{Columns: cols} + dest := make([]sql.NullString, len(cols)) + scanArgs := make([]any, len(cols)) + for i := range dest { + scanArgs[i] = &dest[i] + } + for rows.Next() { + if err := rows.Scan(scanArgs...); err != nil { + return ai.QueryResult{}, err + } + row := make([]string, len(cols)) + for i, v := range dest { + row[i] = v.String + } + result.Rows = append(result.Rows, row) + } + return result, rows.Err() +} + +func (s *SafeQueryStore) runCount(ctx context.Context, entity, where string, args []any) (ai.QueryResult, error) { + query := fmt.Sprintf("SELECT COUNT(*) FROM %s%s", entity, where) + var count int64 + if err := s.db.QueryRowContext(ctx, query, args...).Scan(&count); err != nil { + return ai.QueryResult{}, err + } + return ai.QueryResult{Columns: []string{"count"}, Rows: [][]string{{fmt.Sprintf("%d", count)}}}, nil +} + +func (s *SafeQueryStore) runGroupBy(ctx context.Context, entity, groupBy, where string, args []any) (ai.QueryResult, error) { + if !ai.AllowedFields[entity][groupBy] { + return ai.QueryResult{}, fmt.Errorf("postgres: group_by field %q not allowed for entity %q", groupBy, entity) + } + query := fmt.Sprintf("SELECT %s, COUNT(*) FROM %s%s GROUP BY %s ORDER BY COUNT(*) DESC", groupBy, entity, where, groupBy) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return ai.QueryResult{}, err + } + defer rows.Close() + + result := ai.QueryResult{Columns: []string{groupBy, "count"}} + for rows.Next() { + var key sql.NullString + var count int64 + if err := rows.Scan(&key, &count); err != nil { + return ai.QueryResult{}, err + } + result.Rows = append(result.Rows, []string{key.String, fmt.Sprintf("%d", count)}) + } + return result, rows.Err() +} + +// buildWhereClause builds a parameterized WHERE clause. Every value +// is bound as a placeholder, never interpolated into the query +// string — the only thing that gets string-formatted into the SQL +// itself is the field name and operator, both of which are +// re-checked against the allowlist immediately below, not just +// trusted from the caller. +func buildWhereClause(intent ai.QueryIntent) (string, []any, error) { + if len(intent.Filters) == 0 { + return "", nil, nil + } + fields := ai.AllowedFields[intent.Entity] + + var clauses []string + var args []any + for _, f := range intent.Filters { + if !fields[f.Field] { + return "", nil, fmt.Errorf("postgres: filter field %q not allowed for entity %q", f.Field, intent.Entity) + } + op, ok := operatorSQL[f.Operator] + if !ok { + return "", nil, fmt.Errorf("postgres: operator %q not allowed", f.Operator) + } + args = append(args, valueForOperator(f.Operator, f.Value)) + clauses = append(clauses, fmt.Sprintf("%s %s $%d", f.Field, op, len(args))) + } + return " WHERE " + strings.Join(clauses, " AND "), args, nil +} + +func valueForOperator(operator, value string) string { + if operator == "contains" { + return "%" + value + "%" + } + return value +} + +var _ ai.QueryableStore = (*SafeQueryStore)(nil) diff --git a/store/postgres/session_store.go b/store/postgres/session_store.go index feaa5a8..8b3531b 100644 --- a/store/postgres/session_store.go +++ b/store/postgres/session_store.go @@ -8,7 +8,7 @@ import ( "github.com/crydensync/cryden/v2/store" ) -// SessionStore is the v2 production store.SessionStore implementation. +// SessionStore is the v1 production store.SessionStore implementation. type SessionStore struct { db *sql.DB } @@ -146,4 +146,12 @@ func (s *SessionStore) RotateToken(ctx context.Context, oldSessionID string, new return tx.Commit() } +func (s *SessionStore) CountActive(ctx context.Context) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM sessions WHERE revoked_at IS NULL + `).Scan(&count) + return count, err +} + var _ store.SessionStore = (*SessionStore)(nil) diff --git a/store/postgres/user_store.go b/store/postgres/user_store.go index 76a9613..4597133 100644 --- a/store/postgres/user_store.go +++ b/store/postgres/user_store.go @@ -11,7 +11,7 @@ import ( "github.com/crydensync/cryden/v2/store" ) -// UserStore is the v2 production store.UserStore implementation. +// UserStore is the v1 production store.UserStore implementation. type UserStore struct { db *sql.DB } @@ -138,4 +138,37 @@ func (s *UserStore) LockAccount(ctx context.Context, id string, until time.Time) return checkRowsAffected(result) } +func (s *UserStore) ListAll(ctx context.Context, limit, offset int) ([]store.User, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, email, password_hash, failed_attempts, locked_until, created_at, updated_at + FROM users + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []store.User{} + for rows.Next() { + var u store.User + var lockedUntil sql.NullTime + if err := rows.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.FailedAttempts, &lockedUntil, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, err + } + if lockedUntil.Valid { + u.LockedUntil = &lockedUntil.Time + } + out = append(out, u) + } + return out, rows.Err() +} + +func (s *UserStore) Count(ctx context.Context) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count) + return count, err +} + var _ store.UserStore = (*UserStore)(nil)