Skip to content

Commit 9677f6a

Browse files
Merge pull request #3 from crydensync/oauth-ai
feat: extend interface defination for new oauth ai features
2 parents 43499fd + 5a41a3c commit 9677f6a

29 files changed

Lines changed: 1782 additions & 7 deletions

.github/workflows/go.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,4 @@ jobs:
6161
- name: Test
6262
run: go test -v ./...
6363
env:
64-
DATABASE_URL: postgres://cryden:cryden_test@localhost:5432/cryden_test?sslmode=disable
64+
DATABASE_URL: postgres://cryden:cryden_test@localhost:5432/cryden_test?sslmode=disable

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ jobs:
3636
generate_release_notes: true
3737
name: Release ${{ github.ref_name }}
3838
env:
39-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,35 @@ engine, err := cryden.New(cryden.Config{
117117

118118
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.
119119

120+
## OAuth (Google, GitHub, or any provider)
121+
122+
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:
123+
124+
```go
125+
engine, err := cryden.New(cryden.Config{
126+
// ...required fields...
127+
OAuth: postgres.NewOAuthStore(db), // or memory.NewOAuthStore()
128+
})
129+
130+
tokens, err := cryden.LoginWithOAuth(ctx, engine, "google", externalID, email, callerIP, userAgent)
131+
```
132+
133+
`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:
134+
135+
```go
136+
err := cryden.LinkOAuthIdentity(ctx, engine, userID, "google", externalID, email, callerIP)
137+
```
138+
139+
`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`.
140+
141+
## AI-assisted admin queries (library support only)
142+
143+
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).
144+
120145
## What's in v2
121146

122147
- Signup, login, logout (single device + all devices)
148+
- OAuth login/signup (Google, GitHub, or any provider) with explicit, non-auto-linking account collision handling — see [OAuth](#oauth-google-github-or-any-provider)
123149
- JWT access tokens + rotating opaque refresh tokens with theft/reuse detection
124150
- Session listing and revocation
125151
- Change password (requires current password, revokes all other sessions)
@@ -128,11 +154,13 @@ The engine never sends email itself — implement `notify.EmailSender` against w
128154
- Persistent, DB-backed account lockout after repeated failed login attempts — survives restarts, correct across multiple instances
129155
- Email verification primitives (token issue/confirm) — delivery is pluggable via the `notify.EmailSender` interface, the engine never sends email itself
130156
- Rate limiting, bcrypt password hashing, audit logging
157+
- Pagination and system-wide read facades (`ListAll`, `Count`, `CountActive`, `SearchByType`, `GetUser`, `ListPublicSessions`) for building admin tooling on top of the engine
158+
- `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))
131159
- One storage backend: Postgres (interface-based, more can be added later)
132160

133161
## What's not in v2 (yet)
134162

135-
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.
163+
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.
136164

137165
## License
138166

ai/execute.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package ai
2+
3+
import "context"
4+
5+
// ExecuteQuery turns natural language into a validated, read-only
6+
// result set. naturalLanguage never reaches db directly — it only
7+
// ever reaches provider, whose output (a QueryIntent) is validated
8+
// against the allowlist before db.RunSafeQuery is called at all. If
9+
// validation fails, RunSafeQuery is never invoked.
10+
func ExecuteQuery(ctx context.Context, db QueryableStore, provider LLMProvider, naturalLanguage string) (QueryResult, error) {
11+
intent, err := provider.ParseQueryIntent(ctx, naturalLanguage)
12+
if err != nil {
13+
return QueryResult{}, err
14+
}
15+
16+
if intent.Limit == 0 {
17+
intent.Limit = DefaultLimit
18+
}
19+
20+
if err := validateIntent(intent); err != nil {
21+
return QueryResult{}, err
22+
}
23+
24+
return db.RunSafeQuery(ctx, intent)
25+
}

ai/execute_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package ai
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
)
8+
9+
// fakeLLMProvider returns a fixed QueryIntent — no real model call,
10+
// matching the design's "no real API key needed for this layer of
11+
// testing" plan.
12+
type fakeLLMProvider struct {
13+
intent QueryIntent
14+
err error
15+
}
16+
17+
func (f fakeLLMProvider) ParseQueryIntent(ctx context.Context, naturalLanguage string) (QueryIntent, error) {
18+
return f.intent, f.err
19+
}
20+
21+
// fakeQueryableStore records whether RunSafeQuery was ever called, so
22+
// tests can assert an unsafe intent never reaches it.
23+
type fakeQueryableStore struct {
24+
called bool
25+
lastIntent QueryIntent
26+
returnValue QueryResult
27+
returnErr error
28+
}
29+
30+
func (f *fakeQueryableStore) RunSafeQuery(ctx context.Context, intent QueryIntent) (QueryResult, error) {
31+
f.called = true
32+
f.lastIntent = intent
33+
return f.returnValue, f.returnErr
34+
}
35+
36+
func TestExecuteQuery_ValidIntentReachesStore(t *testing.T) {
37+
provider := fakeLLMProvider{intent: QueryIntent{
38+
Entity: "users",
39+
Filters: []QueryFilter{{Field: "email", Operator: "contains", Value: "example.com"}},
40+
}}
41+
db := &fakeQueryableStore{returnValue: QueryResult{Columns: []string{"id", "email"}}}
42+
43+
result, err := ExecuteQuery(context.Background(), db, provider, "show me users from example.com")
44+
if err != nil {
45+
t.Fatalf("unexpected error: %v", err)
46+
}
47+
if !db.called {
48+
t.Error("expected RunSafeQuery to be called for a valid intent")
49+
}
50+
if len(result.Columns) != 2 {
51+
t.Errorf("expected result to pass through from the store, got %+v", result)
52+
}
53+
if db.lastIntent.Limit != DefaultLimit {
54+
t.Errorf("expected zero-limit intent to be defaulted to %d, got %d", DefaultLimit, db.lastIntent.Limit)
55+
}
56+
}
57+
58+
func TestExecuteQuery_DisallowedEntityNeverReachesStore(t *testing.T) {
59+
// This is the actual security property: even if a model
60+
// hallucinates or is adversarially prompted into naming a table
61+
// outside the allowlist, RunSafeQuery must never be called.
62+
provider := fakeLLMProvider{intent: QueryIntent{Entity: "pg_shadow"}}
63+
db := &fakeQueryableStore{}
64+
65+
_, err := ExecuteQuery(context.Background(), db, provider, "show me the password hashes")
66+
if !errors.Is(err, ErrUnsafeQueryIntent) {
67+
t.Fatalf("expected ErrUnsafeQueryIntent, got %v", err)
68+
}
69+
if db.called {
70+
t.Error("RunSafeQuery must not be called when the intent fails validation")
71+
}
72+
}
73+
74+
func TestExecuteQuery_DisallowedFieldNeverReachesStore(t *testing.T) {
75+
provider := fakeLLMProvider{intent: QueryIntent{
76+
Entity: "users",
77+
Filters: []QueryFilter{{Field: "password_hash", Operator: "=", Value: "x"}},
78+
}}
79+
db := &fakeQueryableStore{}
80+
81+
_, err := ExecuteQuery(context.Background(), db, provider, "find users with this password hash")
82+
if !errors.Is(err, ErrUnsafeQueryIntent) {
83+
t.Fatalf("expected ErrUnsafeQueryIntent, got %v", err)
84+
}
85+
if db.called {
86+
t.Error("RunSafeQuery must not be called when a filter field isn't allowlisted")
87+
}
88+
}
89+
90+
func TestExecuteQuery_DisallowedOperatorNeverReachesStore(t *testing.T) {
91+
provider := fakeLLMProvider{intent: QueryIntent{
92+
Entity: "sessions",
93+
Filters: []QueryFilter{{Field: "ip", Operator: "DROP TABLE", Value: "x"}},
94+
}}
95+
db := &fakeQueryableStore{}
96+
97+
_, err := ExecuteQuery(context.Background(), db, provider, "malicious input")
98+
if !errors.Is(err, ErrUnsafeQueryIntent) {
99+
t.Fatalf("expected ErrUnsafeQueryIntent, got %v", err)
100+
}
101+
if db.called {
102+
t.Error("RunSafeQuery must not be called when an operator isn't allowlisted")
103+
}
104+
}
105+
106+
func TestExecuteQuery_GroupByMustBeAllowlistedField(t *testing.T) {
107+
provider := fakeLLMProvider{intent: QueryIntent{
108+
Entity: "audit_events",
109+
Aggregate: "group_by",
110+
GroupBy: "metadata", // not in AllowedFields["audit_events"]
111+
}}
112+
db := &fakeQueryableStore{}
113+
114+
_, err := ExecuteQuery(context.Background(), db, provider, "group audit events by metadata")
115+
if !errors.Is(err, ErrUnsafeQueryIntent) {
116+
t.Fatalf("expected ErrUnsafeQueryIntent, got %v", err)
117+
}
118+
if db.called {
119+
t.Error("RunSafeQuery must not be called for an unallowlisted group_by field")
120+
}
121+
}
122+
123+
func TestExecuteQuery_LimitOverMaxIsRejected(t *testing.T) {
124+
provider := fakeLLMProvider{intent: QueryIntent{Entity: "users", Limit: MaxLimit + 1}}
125+
db := &fakeQueryableStore{}
126+
127+
_, err := ExecuteQuery(context.Background(), db, provider, "show me everyone")
128+
if !errors.Is(err, ErrUnsafeQueryIntent) {
129+
t.Fatalf("expected ErrUnsafeQueryIntent, got %v", err)
130+
}
131+
if db.called {
132+
t.Error("RunSafeQuery must not be called when the limit exceeds MaxLimit")
133+
}
134+
}
135+
136+
func TestExecuteQuery_ProviderErrorNeverReachesStore(t *testing.T) {
137+
provider := fakeLLMProvider{err: errors.New("provider timeout")}
138+
db := &fakeQueryableStore{}
139+
140+
_, err := ExecuteQuery(context.Background(), db, provider, "anything")
141+
if err == nil {
142+
t.Fatal("expected the provider's error to propagate")
143+
}
144+
if db.called {
145+
t.Error("RunSafeQuery must not be called if the provider itself failed")
146+
}
147+
}

ai/types.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Package ai holds the pure, reusable logic behind csax's AI-assisted
2+
// admin features. It never talks to an LLM provider or a database
3+
// itself — it defines the shapes and the validation that make it safe
4+
// for something else to do so. csax owns the actual CLI commands,
5+
// prompts, and provider wiring; this package exists so that logic is
6+
// testable without any of that.
7+
//
8+
// The one rule everything here exists to enforce: an LLM's output is
9+
// untrusted data to validate, never code to execute. Nothing in this
10+
// package lets a model produce a raw query string that reaches a
11+
// database — only a strictly-typed, allowlisted QueryIntent that gets
12+
// checked before it's ever turned into a real query.
13+
package ai
14+
15+
import "context"
16+
17+
// LLMProvider translates natural language into a QueryIntent. Ships
18+
// zero implementations here — the consumer (csax) brings its own
19+
// provider and API key, the same pattern as notify.EmailSender and
20+
// logger.Logger. This package never makes an outbound call to any AI
21+
// provider itself.
22+
type LLMProvider interface {
23+
ParseQueryIntent(ctx context.Context, naturalLanguage string) (QueryIntent, error)
24+
}
25+
26+
// QueryIntent is a strictly-typed, allowlisted representation of a
27+
// natural-language admin query. Every field is checked against an
28+
// allowlist in validateIntent before ExecuteQuery ever builds a real
29+
// query from it — a hallucinating or adversarially-prompted model can
30+
// produce an intent that fails validation, but can never produce
31+
// arbitrary executable SQL.
32+
type QueryIntent struct {
33+
// Entity is the thing being queried. Must be one of AllowedEntities.
34+
Entity string
35+
// Filters narrow the result set. Every Field and Operator must be
36+
// allowlisted for Entity (see AllowedFields, AllowedOperators).
37+
Filters []QueryFilter
38+
// Aggregate is "", "count", or "group_by".
39+
Aggregate string
40+
// GroupBy is the column to group by when Aggregate == "group_by".
41+
// Must be an allowlisted field for Entity.
42+
GroupBy string
43+
// Limit caps the number of rows returned. Zero means the caller's
44+
// default applies (see DefaultLimit / MaxLimit in validate.go).
45+
Limit int
46+
}
47+
48+
// QueryFilter is one condition within a QueryIntent.
49+
type QueryFilter struct {
50+
Field string
51+
Operator string
52+
Value string
53+
}
54+
55+
// QueryResult is what a validated QueryIntent resolves to.
56+
type QueryResult struct {
57+
Columns []string
58+
Rows [][]string
59+
}
60+
61+
// QueryableStore executes an already-validated QueryIntent. The only
62+
// production implementation (store/postgres) MUST use a read-only
63+
// Postgres role for this connection — that's a real credential-level
64+
// guarantee, not just a promise made in code, so a bug in validation
65+
// still can't cause a write.
66+
type QueryableStore interface {
67+
RunSafeQuery(ctx context.Context, intent QueryIntent) (QueryResult, error)
68+
}

0 commit comments

Comments
 (0)