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

136
migrations/0001_init.sql Normal file
View file

@ -0,0 +1,136 @@
-- 0001_init.sql
-- Initial authkit schema for PostgreSQL 16+. All tables prefixed authkit_ so
-- the library can be embedded in an existing application database. Each
-- migration owns its transaction and inserts its version row at the bottom;
-- the runner only orchestrates file discovery and concurrency.
BEGIN;
CREATE TABLE IF NOT EXISTS authkit_schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL
);
-- Users. Password is nullable so accounts can be created without a credential
-- and have one set later (invite flows, magic-link-only accounts, etc.).
CREATE TABLE IF NOT EXISTS authkit_users (
id UUID PRIMARY KEY,
email TEXT NOT NULL,
email_normalized TEXT NOT NULL,
email_verified_at TIMESTAMPTZ,
password_hash TEXT,
session_version INTEGER NOT NULL DEFAULT 0,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS authkit_users_email_normalized_uniq
ON authkit_users (email_normalized);
-- Opaque server-side sessions.
CREATE TABLE IF NOT EXISTS authkit_sessions (
id_hash BYTEA PRIMARY KEY,
user_id UUID NOT NULL REFERENCES authkit_users(id) ON DELETE CASCADE,
user_agent TEXT NOT NULL DEFAULT '',
ip TEXT,
created_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS authkit_sessions_user_id_idx ON authkit_sessions(user_id);
CREATE INDEX IF NOT EXISTS authkit_sessions_expires_at_idx ON authkit_sessions(expires_at);
-- Single-use tokens (refresh, email-verify, password-reset, magic-link, email-otp).
-- attempts_remaining is non-null only for tokens that allow retries (email_otp);
-- ConsumeToken decrements and zeroes-out on exhaustion.
CREATE TABLE IF NOT EXISTS authkit_tokens (
hash BYTEA NOT NULL,
kind TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES authkit_users(id) ON DELETE CASCADE,
chain_id TEXT,
consumed_at TIMESTAMPTZ,
attempts_remaining INTEGER,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (kind, hash)
);
CREATE INDEX IF NOT EXISTS authkit_tokens_user_id_idx ON authkit_tokens(user_id);
CREATE INDEX IF NOT EXISTS authkit_tokens_expires_at_idx ON authkit_tokens(expires_at);
CREATE INDEX IF NOT EXISTS authkit_tokens_chain_id_idx
ON authkit_tokens(chain_id) WHERE chain_id IS NOT NULL;
-- Service tokens. No owner column: these are machine credentials, intended to
-- be created by applications for outbound API calls or inbound automation.
-- Consumers tag them with whatever metadata they need via Name.
CREATE TABLE IF NOT EXISTS authkit_service_keys (
id_hash BYTEA PRIMARY KEY,
name TEXT NOT NULL,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
-- Roles, permissions, and abilities are seeded by the consumer (typically via
-- the cmd/roles, cmd/perms, cmd/abilities CLIs). They share the same shape:
-- normalised slug as the unique business key, optional human label.
CREATE TABLE IF NOT EXISTS authkit_roles (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
label TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS authkit_permissions (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
label TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS authkit_abilities (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
label TEXT,
created_at TIMESTAMPTZ NOT NULL
);
-- Role ↔ Permission (defines what permissions a role grants).
CREATE TABLE IF NOT EXISTS authkit_role_permissions (
role_id UUID NOT NULL REFERENCES authkit_roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES authkit_permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
-- User ↔ Role (which roles a user holds).
CREATE TABLE IF NOT EXISTS authkit_user_roles (
user_id UUID NOT NULL REFERENCES authkit_users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES authkit_roles(id) ON DELETE CASCADE,
granted_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS authkit_user_roles_role_id_idx ON authkit_user_roles(role_id);
-- User ↔ Permission (direct grants, in addition to permissions resolved
-- through roles). GetUserPermissions returns the UNION of both paths.
CREATE TABLE IF NOT EXISTS authkit_user_permissions (
user_id UUID NOT NULL REFERENCES authkit_users(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES authkit_permissions(id) ON DELETE CASCADE,
granted_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (user_id, permission_id)
);
CREATE INDEX IF NOT EXISTS authkit_user_permissions_perm_id_idx ON authkit_user_permissions(permission_id);
-- ServiceKey ↔ Ability (which abilities a service key carries).
CREATE TABLE IF NOT EXISTS authkit_service_key_abilities (
service_key_id_hash BYTEA NOT NULL REFERENCES authkit_service_keys(id_hash) ON DELETE CASCADE,
ability_id UUID NOT NULL REFERENCES authkit_abilities(id) ON DELETE CASCADE,
granted_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (service_key_id_hash, ability_id)
);
CREATE INDEX IF NOT EXISTS authkit_service_key_abilities_ability_idx ON authkit_service_key_abilities(ability_id);
INSERT INTO authkit_schema_migrations (version, applied_at) VALUES ('0001_init', now())
ON CONFLICT (version) DO NOTHING;
COMMIT;