Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ jobs:
- name: Test
run: go test -v ./...
env:
DATABASE_URL: postgres://cryden:cryden_test@localhost:5432/cryden_test?sslmode=disable
DATABASE_URL: postgres://cryden:cryden_test@localhost:5432/cryden_test?sslmode=disable
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ jobs:
generate_release_notes: true
name: Release ${{ github.ref_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down
25 changes: 25 additions & 0 deletions ai/execute.go
Original file line number Diff line number Diff line change
@@ -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)
}
147 changes: 147 additions & 0 deletions ai/execute_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
68 changes: 68 additions & 0 deletions ai/types.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading