Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API
Drops the Dialect/Queries abstraction in favor of a single PostgreSQL 16+ implementation collapsed into the root authkit package, removes the public store interfaces, and reshapes the authorization model around seeded slugs (roles, permissions, abilities) with optional labels. Schema is now squashed into one migrations/0001_init.sql and applied automatically on authkit.New (opt-out via Config.SkipAutoMigrate). A schema verifier checks tables/columns/types/nullability on startup, tolerates extra columns, and falls back to default table names when a configured override is missing. Auth API: CreateUser + SetPassword replace Register; password is nullable. Email OTP (RequestEmailOTP/ConsumeEmailOTP) joins magic links and password reset, all with anti-enumeration silent-success defaults and a Config.RevealUnknownEmail opt-in. Service tokens drop owner columns and validate ability slugs against authkit_abilities at issue. Direct user permissions live alongside role-derived ones; queries return their UNION. Predicate API: HasRole/HasPermission/HasAbility leaves with AnyLogin/AllLogin/AnyServiceKey/AllServiceKey combinators. Validate runs at middleware construction, panicking on unknown slugs. Middleware collapses to RequireLogin (cookie + JWT), RequireGuest (configurable OnAuthenticated), and RequireServiceKey. UserIDFromCtx / UserFromCtx (lazy) / RefreshUserInCtx provide request-lifetime user caching. Cookie defaults flip to Secure=true and HttpOnly=true via *bool with BoolPtr opt-out. CLIs ship under cmd/perms, cmd/roles, cmd/abilities for seeding the authorization vocabulary; the library never seeds rows itself. Tests cover unit-level (slug validation + fuzz, opaque secrets, email normalization, extractors, predicates, OTP generator) and integration flows gated on AUTHKIT_TEST_DATABASE_URL (every Auth method, schema drift detection, migration idempotency, lazy user cache, all middleware paths). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7f1db871bc
commit
d3c5367492
80 changed files with 5605 additions and 4565 deletions
249
store_queries.go
Normal file
249
store_queries.go
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
package authkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// queries holds every SQL string the store issues, with table identifiers
|
||||
// already substituted from a validated Schema. Built once at New() to avoid
|
||||
// per-call concatenation. Identifiers are interpolated via concatenation,
|
||||
// safe because Schema.Validate gated them through identifierRE.
|
||||
type queries struct {
|
||||
// users
|
||||
createUser string
|
||||
getUserByID string
|
||||
getUserByEmail string
|
||||
updateUser string
|
||||
deleteUser string
|
||||
setPassword string
|
||||
setEmailVerified string
|
||||
bumpSessionVersion string
|
||||
|
||||
// sessions
|
||||
createSession string
|
||||
getSession string
|
||||
touchSession string
|
||||
deleteSession string
|
||||
deleteUserSessions string
|
||||
deleteExpiredSessions string
|
||||
|
||||
// tokens
|
||||
createToken string
|
||||
consumeToken string
|
||||
getToken string
|
||||
getOTPForUser string
|
||||
decrementOTPAttempt string
|
||||
consumeOTPByID string
|
||||
deleteByChain string
|
||||
deleteExpiredTokens string
|
||||
|
||||
// service keys
|
||||
createServiceKey string
|
||||
getServiceKey string
|
||||
listServiceKeys string
|
||||
touchServiceKey string
|
||||
revokeServiceKey string
|
||||
getServiceKeyAbilities string
|
||||
insertServiceKeyAbil string
|
||||
|
||||
// roles
|
||||
createRole string
|
||||
getRoleByID string
|
||||
getRoleBySlug string
|
||||
listRoles string
|
||||
deleteRole string
|
||||
assignRoleToUser string
|
||||
removeRoleFromUser string
|
||||
getUserRoles string
|
||||
hasAnyRolePrefix string
|
||||
|
||||
// permissions
|
||||
createPermission string
|
||||
getPermissionByID string
|
||||
getPermissionBySlug string
|
||||
listPermissions string
|
||||
deletePermission string
|
||||
assignPermissionToRole string
|
||||
removePermissionFromRole string
|
||||
getRolePermissions string
|
||||
getUserPermissions string
|
||||
|
||||
// direct user permissions
|
||||
grantPermissionToUser string
|
||||
revokePermissionFromUser string
|
||||
|
||||
// abilities
|
||||
createAbility string
|
||||
getAbilityByID string
|
||||
getAbilityBySlug string
|
||||
listAbilities string
|
||||
deleteAbility string
|
||||
|
||||
// migrations
|
||||
createMigrationsTable string
|
||||
selectAppliedVersions string
|
||||
}
|
||||
|
||||
func buildQueries(t Tables) queries {
|
||||
return queries{
|
||||
// users
|
||||
createUser: `INSERT INTO ` + t.Users + `
|
||||
(id, email, email_normalized, email_verified_at, password_hash,
|
||||
session_version, last_login_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
getUserByID: `SELECT id, email, email_normalized, email_verified_at,
|
||||
password_hash, session_version, last_login_at, created_at, updated_at
|
||||
FROM ` + t.Users + ` WHERE id = $1`,
|
||||
getUserByEmail: `SELECT id, email, email_normalized, email_verified_at,
|
||||
password_hash, session_version, last_login_at, created_at, updated_at
|
||||
FROM ` + t.Users + ` WHERE email_normalized = $1`,
|
||||
updateUser: `UPDATE ` + t.Users + ` SET
|
||||
email = $1, email_normalized = $2, email_verified_at = $3,
|
||||
password_hash = $4, session_version = $5,
|
||||
last_login_at = $6, updated_at = $7
|
||||
WHERE id = $8`,
|
||||
deleteUser: `DELETE FROM ` + t.Users + ` WHERE id = $1`,
|
||||
setPassword: `UPDATE ` + t.Users + ` SET password_hash = $1, updated_at = $2 WHERE id = $3`,
|
||||
setEmailVerified: `UPDATE ` + t.Users + ` SET email_verified_at = $1, updated_at = $2 WHERE id = $3`,
|
||||
bumpSessionVersion: `UPDATE ` + t.Users + ` SET session_version = session_version + 1, updated_at = $1 WHERE id = $2 RETURNING session_version`,
|
||||
|
||||
// sessions
|
||||
createSession: `INSERT INTO ` + t.Sessions + `
|
||||
(id_hash, user_id, user_agent, ip, created_at, last_seen_at, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
getSession: `SELECT id_hash, user_id, user_agent, ip, created_at, last_seen_at, expires_at
|
||||
FROM ` + t.Sessions + ` WHERE id_hash = $1`,
|
||||
touchSession: `UPDATE ` + t.Sessions + ` SET last_seen_at = $1, expires_at = $2 WHERE id_hash = $3`,
|
||||
deleteSession: `DELETE FROM ` + t.Sessions + ` WHERE id_hash = $1`,
|
||||
deleteUserSessions: `DELETE FROM ` + t.Sessions + ` WHERE user_id = $1`,
|
||||
deleteExpiredSessions: `DELETE FROM ` + t.Sessions + ` WHERE expires_at <= $1`,
|
||||
|
||||
// tokens
|
||||
createToken: `INSERT INTO ` + t.Tokens + `
|
||||
(hash, kind, user_id, chain_id, consumed_at, attempts_remaining, created_at, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
consumeToken: `UPDATE ` + t.Tokens + `
|
||||
SET consumed_at = $1
|
||||
WHERE kind = $2 AND hash = $3 AND consumed_at IS NULL AND expires_at > $4
|
||||
RETURNING hash, kind, user_id, chain_id, consumed_at, attempts_remaining, created_at, expires_at`,
|
||||
getToken: `SELECT hash, kind, user_id, chain_id, consumed_at, attempts_remaining, created_at, expires_at
|
||||
FROM ` + t.Tokens + ` WHERE kind = $1 AND hash = $2`,
|
||||
// getOTPForUser returns the most recent unconsumed, unexpired OTP for
|
||||
// the user, used to verify a code by hash-comparing client input.
|
||||
getOTPForUser: `SELECT hash, kind, user_id, chain_id, consumed_at, attempts_remaining, created_at, expires_at
|
||||
FROM ` + t.Tokens + `
|
||||
WHERE kind = $1 AND user_id = $2 AND consumed_at IS NULL AND expires_at > $3
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
// decrementOTPAttempt drops attempts_remaining by 1 and consumes the
|
||||
// row when it hits zero. Used after a wrong-code submission.
|
||||
decrementOTPAttempt: `UPDATE ` + t.Tokens + `
|
||||
SET attempts_remaining = GREATEST(COALESCE(attempts_remaining, 0) - 1, 0),
|
||||
consumed_at = CASE WHEN COALESCE(attempts_remaining, 0) - 1 <= 0 THEN $1 ELSE consumed_at END
|
||||
WHERE kind = $2 AND hash = $3 AND consumed_at IS NULL AND expires_at > $1
|
||||
RETURNING attempts_remaining`,
|
||||
// consumeOTPByID is the success path: mark the matched OTP consumed.
|
||||
consumeOTPByID: `UPDATE ` + t.Tokens + `
|
||||
SET consumed_at = $1
|
||||
WHERE kind = $2 AND hash = $3 AND consumed_at IS NULL AND expires_at > $1
|
||||
RETURNING hash, kind, user_id, chain_id, consumed_at, attempts_remaining, created_at, expires_at`,
|
||||
deleteByChain: `DELETE FROM ` + t.Tokens + ` WHERE chain_id = $1`,
|
||||
deleteExpiredTokens: `DELETE FROM ` + t.Tokens + ` WHERE expires_at <= $1`,
|
||||
|
||||
// service keys
|
||||
createServiceKey: `INSERT INTO ` + t.ServiceKeys + `
|
||||
(id_hash, name, last_used_at, created_at, expires_at, revoked_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
getServiceKey: `SELECT id_hash, name, last_used_at, created_at, expires_at, revoked_at
|
||||
FROM ` + t.ServiceKeys + ` WHERE id_hash = $1`,
|
||||
listServiceKeys: `SELECT id_hash, name, last_used_at, created_at, expires_at, revoked_at
|
||||
FROM ` + t.ServiceKeys + ` ORDER BY created_at DESC`,
|
||||
touchServiceKey: `UPDATE ` + t.ServiceKeys + ` SET last_used_at = $1 WHERE id_hash = $2`,
|
||||
revokeServiceKey: `UPDATE ` + t.ServiceKeys + ` SET revoked_at = $1 WHERE id_hash = $2 AND revoked_at IS NULL`,
|
||||
getServiceKeyAbilities: `SELECT a.slug FROM ` + t.Abilities + ` a
|
||||
JOIN ` + t.ServiceKeyAbilities + ` ska ON ska.ability_id = a.id
|
||||
WHERE ska.service_key_id_hash = $1 ORDER BY a.slug`,
|
||||
insertServiceKeyAbil: `INSERT INTO ` + t.ServiceKeyAbilities + `
|
||||
(service_key_id_hash, ability_id, granted_at) VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
|
||||
// roles
|
||||
createRole: `INSERT INTO ` + t.Roles + ` (id, slug, label, created_at) VALUES ($1, $2, $3, $4)`,
|
||||
getRoleByID: `SELECT id, slug, label, created_at FROM ` + t.Roles + ` WHERE id = $1`,
|
||||
getRoleBySlug: `SELECT id, slug, label, created_at FROM ` + t.Roles + ` WHERE slug = $1`,
|
||||
listRoles: `SELECT id, slug, label, created_at FROM ` + t.Roles + ` ORDER BY slug`,
|
||||
deleteRole: `DELETE FROM ` + t.Roles + ` WHERE id = $1`,
|
||||
assignRoleToUser: `INSERT INTO ` + t.UserRoles + ` (user_id, role_id, granted_at)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
removeRoleFromUser: `DELETE FROM ` + t.UserRoles + ` WHERE user_id = $1 AND role_id = $2`,
|
||||
getUserRoles: `SELECT r.id, r.slug, r.label, r.created_at
|
||||
FROM ` + t.Roles + ` r JOIN ` + t.UserRoles + ` ur ON ur.role_id = r.id
|
||||
WHERE ur.user_id = $1 ORDER BY r.slug`,
|
||||
hasAnyRolePrefix: `SELECT EXISTS (
|
||||
SELECT 1 FROM ` + t.UserRoles + ` ur JOIN ` + t.Roles + ` r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = $1 AND r.slug IN (`,
|
||||
|
||||
// permissions
|
||||
createPermission: `INSERT INTO ` + t.Permissions + ` (id, slug, label, created_at) VALUES ($1, $2, $3, $4)`,
|
||||
getPermissionByID: `SELECT id, slug, label, created_at FROM ` + t.Permissions + ` WHERE id = $1`,
|
||||
getPermissionBySlug: `SELECT id, slug, label, created_at FROM ` + t.Permissions + ` WHERE slug = $1`,
|
||||
listPermissions: `SELECT id, slug, label, created_at FROM ` + t.Permissions + ` ORDER BY slug`,
|
||||
deletePermission: `DELETE FROM ` + t.Permissions + ` WHERE id = $1`,
|
||||
assignPermissionToRole: `INSERT INTO ` + t.RolePermissions + ` (role_id, permission_id)
|
||||
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
removePermissionFromRole: `DELETE FROM ` + t.RolePermissions + ` WHERE role_id = $1 AND permission_id = $2`,
|
||||
getRolePermissions: `SELECT p.id, p.slug, p.label, p.created_at
|
||||
FROM ` + t.Permissions + ` p JOIN ` + t.RolePermissions + ` rp ON rp.permission_id = p.id
|
||||
WHERE rp.role_id = $1 ORDER BY p.slug`,
|
||||
// UNION of role-derived and direct user permissions.
|
||||
getUserPermissions: `SELECT DISTINCT p.id, p.slug, p.label, p.created_at FROM ` + t.Permissions + ` p
|
||||
WHERE p.id IN (
|
||||
SELECT rp.permission_id FROM ` + t.RolePermissions + ` rp
|
||||
JOIN ` + t.UserRoles + ` ur ON ur.role_id = rp.role_id
|
||||
WHERE ur.user_id = $1
|
||||
UNION
|
||||
SELECT up.permission_id FROM ` + t.UserPermissions + ` up
|
||||
WHERE up.user_id = $1
|
||||
) ORDER BY p.slug`,
|
||||
|
||||
// direct user permissions
|
||||
grantPermissionToUser: `INSERT INTO ` + t.UserPermissions + `
|
||||
(user_id, permission_id, granted_at) VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
revokePermissionFromUser: `DELETE FROM ` + t.UserPermissions + `
|
||||
WHERE user_id = $1 AND permission_id = $2`,
|
||||
|
||||
// abilities
|
||||
createAbility: `INSERT INTO ` + t.Abilities + ` (id, slug, label, created_at) VALUES ($1, $2, $3, $4)`,
|
||||
getAbilityByID: `SELECT id, slug, label, created_at FROM ` + t.Abilities + ` WHERE id = $1`,
|
||||
getAbilityBySlug: `SELECT id, slug, label, created_at FROM ` + t.Abilities + ` WHERE slug = $1`,
|
||||
listAbilities: `SELECT id, slug, label, created_at FROM ` + t.Abilities + ` ORDER BY slug`,
|
||||
deleteAbility: `DELETE FROM ` + t.Abilities + ` WHERE id = $1`,
|
||||
|
||||
// migrations
|
||||
createMigrationsTable: `CREATE TABLE IF NOT EXISTS ` + t.SchemaMigrations + ` (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL
|
||||
)`,
|
||||
selectAppliedVersions: `SELECT version FROM ` + t.SchemaMigrations,
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyRoleSQL renders the dynamic IN-clause for HasAnyRole. Generated
|
||||
// query is parameterized: $1 = user_id, $2..$N+1 = role slugs.
|
||||
func (q queries) hasAnyRoleSQL(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(q.hasAnyRolePrefix) + 8*n + 4)
|
||||
b.WriteString(q.hasAnyRolePrefix)
|
||||
for i := 0; i < n; i++ {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
fmt.Fprintf(&b, "$%d", i+2)
|
||||
}
|
||||
b.WriteString("))")
|
||||
return b.String()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue