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:
juancwu 2026-04-26 23:27:30 +00:00
commit d3c5367492
80 changed files with 5605 additions and 4565 deletions

249
store_queries.go Normal file
View 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()
}