Documentation
¶
Overview ¶
Package authz @notice Who the caller is: the Principal, carried in the request context.
@dev The package is deliberately database-free on its hot path. Everything a check needs is resolved once per request by the session middleware and read here from the context — because authorization runs per field, per row, and a check that costs a query is an N+1 that only appears under production load.
Index ¶
- Constants
- func AssertAuthCoverage(schema graphql.ExecutableSchema, exempt ...string) error
- func AssertDirectivesWired(directiveRoot any) error
- func Directive(opts DirectiveOptions) ...
- func HasRole(ctx context.Context, role string) bool
- func Scope(ctx context.Context, column string) func(*orm.Query) *orm.Query
- func WithBypassRole(ctx context.Context, role string) context.Context
- func WithPrincipal(ctx context.Context, p *Principal) context.Context
- func WithRLS(ctx context.Context, db *pg.DB, fn func(orm.DB) error) error
- func WithRLSSettings(ctx context.Context, db *pg.DB, extra map[string]string, fn func(orm.DB) error) error
- type Audit
- type AuthLevel
- type DirectiveOptions
- type Event
- type Principal
- type Roles
- func (r *Roles) Ensure(ctx context.Context, db orm.DB, name, description string) error
- func (r *Roles) ForUser(ctx context.Context, db orm.DB, userID string) ([]string, error)
- func (r *Roles) Grant(ctx context.Context, db orm.DB, userID, role string) error
- func (r *Roles) Revoke(ctx context.Context, db orm.DB, userID, role string) error
Constants ¶
const DefaultMFAWindow = 15 * time.Minute
DefaultMFAWindow @notice How recently MFA must have been satisfied for @auth(mfa: true).
const DirectiveSDL = `` /* 195-byte string literal not displayed */
DirectiveSDL @notice The schema snippet to paste into your .graphqls, verbatim.
@dev One composed directive, never a stack of them, and the reason is gqlgen's chaining order: generated code wraps directive0 in directive1 in directive2, so the *last*-declared directive is the outermost and runs *first*. Written left to right, `@auth @hasRole(ADMIN)` runs hasRole before auth — the opposite of how every reader parses it. Rather than document an ordering nobody will remember, the checks compose inside one directive where the order is kal's.
Variables ¶
This section is empty.
Functions ¶
func AssertAuthCoverage ¶
func AssertAuthCoverage(schema graphql.ExecutableSchema, exempt ...string) error
AssertAuthCoverage @notice Fails if any field reachable through the schema can be resolved without an @auth annotation. Call it from a test.
func TestAuthCoverage(t *testing.T) {
schema := generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}})
if err := authz.AssertAuthCoverage(schema, "Query.health", "Mutation.login"); err != nil {
t.Fatal(err)
}
}
@dev Deny-by-default, enforced by a test rather than by discipline, and it is the highest value component in this library. The failure mode of resolver-level authorization is a *forgotten* check, and a forgotten check is invisible: it compiles, it passes review, and it returns data. Walking the schema is the only way to see the absence of something.
It is a test and not a check inside New on purpose. A schema author adding a public field should get a red test they can annotate away, not a server that refuses to boot in production because someone shipped a schema change on a Friday.
Every uncovered field is reported at once, sorted, because an implementer annotating a schema wants the whole list and not a fifty-iteration game of whack-a-mole.
Covered means: the field carries @auth, or its parent type does. A field whose parent type is annotated @auth(requires: ANONYMOUS) is public by explicit decision, which is exactly the distinction this function exists to be able to draw.
@param schema the generated executable schema @param exempt field paths deliberately left unannotated, e.g. "Query.health" @return error naming every uncovered field, or nil
func AssertDirectivesWired ¶
AssertDirectivesWired @notice Fails if any directive implementation on a generated DirectiveRoot is nil.
authz.AssertDirectivesWired(generated.DirectiveRoot{Auth: authz.Directive(...)})
@dev A companion to AssertAuthCoverage rather than part of it, because the two see different things: the schema walk cannot reach the consumer's generated DirectiveRoot struct, which is a type in their module.
It exists because forgetting to wire a directive is a *runtime* error in gqlgen — every annotated field fails with "directive auth is not implemented" on the first request that touches it, and there is no startup validation. Reflection over the struct's func fields is the only way to see that from outside.
@param directiveRoot the generated DirectiveRoot value or pointer @return error naming every unset directive, or nil
func Directive ¶
func Directive(opts DirectiveOptions) func(ctx context.Context, obj any, next graphql.Resolver, requires AuthLevel, roles []string, mfa *bool, proves []string) (any, error)
Directive @notice The @auth implementation, for your generated DirectiveRoot.
c.Directives.Auth = authz.Directive(authz.DirectiveOptions{})
@dev Reads only the context, and never touches the database. That is a hard requirement, not an optimisation: a directive on a field of a list type runs once per row, so a check costing a query is an N+1 that appears only under production load — and luima ships no dataloader to soften it. Everything needed is already on the Principal, resolved once by the middleware.
Nullability interacts with denial, and it is worth knowing before annotating: an error on a nullable field yields null plus an error entry, while on a non-null field it nulls the *parent*, which can blank an entire object. Prefer nullable types for conditionally visible fields.
proves: [] is not the same as an absent proves:. An all-of over an empty set is vacuously true, so the natural implementation allows while the annotation still reads as a restriction, and a schema-generation step that emits [] for an absent list would widen every field it touched without a word. kal denies instead — for anonymous and authenticated callers alike, before any other check — and there is no way to satisfy it. That is the point: nothing writes an empty list on purpose. Omit the argument to mean "no proof requirement".
mfa: true denies until something has stamped auth_sessions.mfa_at, and the window below is measured from it. [session.Sessions.RecordMFA] is that writer and any consumer can reach it, whatever factor it verified first; zkauthn calls it too, so a Groth16 deployment and a TOTP one land in the same column. Failing closed stays the direction — a step-up requirement that silently passes is worse than one that visibly blocks.
The window is compared against the process clock while mfa_at is written by the database's (gotcha 81), so keep both on NTP: the two clocks meet here and nowhere else.
@param opts zero value for the defaults @return func the directive implementation, matching gqlgen's generated field type
func HasRole ¶
HasRole @notice Whether the caller holds the named role, case-insensitively.
@param ctx the resolver context @param role the role to look for @return bool false for an anonymous caller
func Scope ¶
Scope @notice The caller's ownership predicate, as a luima crud option.
@dev This is the real enforcement, and it is the thing a policy engine structurally cannot give you. A boolean answers "may Alice read document 7"; it does not stop a list query from returning every document, and it cannot be applied to a DELETE without a read-then-check round trip that has a TOCTOU window. Composing the predicate into the statement has neither problem, and the database enforces it.
func (r *mutationResolver) DeleteDoc(ctx context.Context, id string) (bool, error) {
return luima.Delete(ctx, r.DB, &model.Doc{ID: id}, authz.Scope(ctx, "owner_id"))
}
What falls out for free: a row that exists but is not yours matches nothing, so RowsAffected() is 0, so crud.Delete returns false and crud.Update reports "not found". "Not yours" and "does not exist" become indistinguishable to the caller — which is the correct answer to give an unauthorized one anyway.
An anonymous caller yields a predicate matching nothing, never an open query. Failing closed matters most exactly where the middleware did not run.
The column name is not user input: it comes from the resolver, at compile time. It is still written through pg.Ident, because a variant that ever took it from a request must not be one edit away from SQL injection.
Escape hatch by function type, not interface: anyone whose authorization genuinely needs an external engine calls it inside their own closure and returns q.Where("id = any(?)", pg.Array(ids)). No Authorizer abstraction that exists to be overridden once.
TOCTOU: every crud helper takes orm.DB, so db.RunInTransaction plus passing the *pg.Tx closes the window between a check and a write, and SELECT … FOR UPDATE is expressible through these same options.
@param ctx the resolver context @param column the owning column on the model's table @return func a query option: the ownership predicate, a no-op for a bypass-role caller, or a predicate matching nothing when the caller is anonymous
func WithBypassRole ¶
WithBypassRole @notice Names the role for which Scope is a no-op — the admin path, as one explicit branch rather than a second set of queries.
@dev Carried on the context rather than read from a package-level var, because a package var would let any consumer in the binary redefine every other consumer's admin role. kal's middleware installs this from Config; a test can install it directly.
@param ctx the parent context @param role the bypass role name; empty installs nothing @return context.Context ctx with the bypass role attached
func WithPrincipal ¶
WithPrincipal @notice Returns a context carrying p. The session middleware's seam — and a test's.
@dev Exported because the middleware lives in another package and tests need to fabricate callers. That does not weaken the unexported-key property: the key prevents *accidental* collision and overwrite; code that imports authz and calls this does it on purpose, in plain sight.
@param ctx the parent context @param p the caller; nil stores nothing and returns ctx unchanged @return context.Context ctx with p reachable through From and Require
func WithRLS ¶
WithRLS @notice Runs fn in a transaction whose Postgres session variables carry the caller, for policies written against current_setting.
@dev The third layer of authorization, and the point of it is that it survives a forgotten check in the two above. Four things about this function are load-bearing, and each of them has silently broken a production deployment:
- Everything happens inside RunInTransaction. SET LOCAL outside a transaction is a no-op — Postgres warns and moves on — and with a connection pool the setting would land on one pooled connection while the query ran on another.
- set_config's third argument is true, which is what makes the setting transaction-local. A plain session-scoped SET outlives the transaction and leaks to whichever request borrows that connection next: a cross-tenant data leak that only appears under concurrency. It is also what keeps this compatible with PgBouncer in transaction pooling mode, precisely because it dies at COMMIT.
- set_config with bound parameters, not "SET LOCAL app.user_id = …". SET is not a parameterizable statement, so the string form forces concatenation, which is SQL injection in the one function whose job is authorization.
- An anonymous caller sets empty strings rather than skipping the settings. A policy must then fail closed — see the warning below.
Two more, on the SQL side, that this function cannot enforce for you:
ALTER TABLE … FORCE ROW LEVEL SECURITY, or the table owner bypasses every policy. Most migration setups connect as the owner, which means RLS silently does nothing. This is the single most common way an RLS deployment is quietly broken.
Never write a policy where a NULL or empty setting is permissive. current_setting('app.user_id', true) returns NULL when unset — the true is mandatory or it raises — so a policy reading "current_setting(...) is null or owner_id = ..." fails open on every unconfigured connection.
A policy with both of those right looks like this. nullif turns the anonymous caller's empty string into NULL, and NULL never equals anything, so the predicate matches no rows:
alter table docs enable row level security;
alter table docs force row level security;
create policy docs_owner on docs
to app_user
using (owner_id = nullif(current_setting('app.user_id', true), '')::uuid)
with check (owner_id = nullif(current_setting('app.user_id', true), '')::uuid);
Prefer this GUC approach over SET ROLE: a client with any SQL-injection foothold can issue RESET ROLE and escape back to the authenticator role, whereas a GUC hands out no role-switching primitive.
@param ctx the resolver context, read for the caller @param db the pool; a transaction is opened on it @param fn runs with the tx, which it must use for every query the policies should see @return error fn's error, or the transaction's
func WithRLSSettings ¶ added in v0.4.0
func WithRLSSettings(ctx context.Context, db *pg.DB, extra map[string]string, fn func(orm.DB) error) error
WithRLSSettings @notice WithRLS, plus consumer settings on the same transaction.
@dev Read WithRLS's doc comment first; all four of its traps apply here unchanged. This exists because a consumer whose tenancy is not a single owner column — an org, a project, a region — otherwise has to copy that function into their own repository and re-derive all four.
The one thing this adds: extra keys are validated against ^app\.[a-z_][a-z0-9_]*$ even though set_config takes its name as a bound parameter and needs no such check. The validation is not load-bearing against injection — it is there so a typo becomes an error instead of a setting no policy reads, which is a policy that silently matches nothing or, worse, silently matches everything.
app.user_id and app.roles are refused; see [reservedSettings] for the bypass that allows.
Keys are applied in sorted order and in one statement, so the query log shows a stable statement rather than one shape per map iteration.
@param ctx the resolver context, read for the caller @param db the pool; a transaction is opened on it @param extra settings to set transaction-locally; keys must be app.-prefixed, and may not be
app.user_id or app.roles. Nil is WithRLS.
@param fn runs with the tx, which it must use for every query the policies should see @return error a rejected key, fn's error, or the transaction's
Types ¶
type Audit ¶ added in v0.4.0
Audit @notice Called for every security-relevant event. Nil discards them.
@dev Same shape as authn.Mailer, and the same reasoning: kal ships no sink, because a sink is a dependency and every deployment already has one. Called synchronously, so an implementation that talks to the network must queue internally — kal will not grow a worker pool for it, the same note authn's notify carries.
Deliberately not a table in kal's migrations: a consumer's audit table needs a tenant column and kal has no tenants. A hook composes; a table does not, and the hook can close over the tenant id so every event is attributed for free.
The hook must not panic and must not block: it runs inside the request, and on the login path it runs inside the credential flow.
func (Audit) Emit ¶ added in v0.4.0
Emit @notice Delivers e, doing nothing when the hook is nil.
@dev Exported because the emitters are other packages — authn and session — and an unexported method would be reachable only from here. The nil check lives in one place so no emit site carries one; a nil hook must be free rather than a branch each site remembered.
At is defaulted here rather than at each site, which is what stops half the events arriving with a zero time. This is the *application* clock, unlike session.RecordMFA where now() is the database's — there the timestamp is a security decision several replicas must agree on, here it is a label on a record the consumer is about to store anyway.
@param ctx the request context, passed through untouched @param e the event; At is filled when zero
type AuthLevel ¶
type AuthLevel string
AuthLevel @notice The @auth directive's `requires` argument.
@dev A kal-owned Go type rather than a generated one, so the directive implementation has a stable signature to match. Bind it in gqlgen.yml:
models:
AuthLevel:
model: github.com/ulas96/kal/authz.AuthLevel
const ( // LevelAnonymous @notice No authentication required. The explicit spelling of "public", // which is what makes AssertAuthCoverage able to tell a deliberate choice from an // oversight. LevelAnonymous AuthLevel = "ANONYMOUS" // LevelAuthenticated @notice A principal is required. LevelAuthenticated AuthLevel = "AUTHENTICATED" )
func (AuthLevel) MarshalGQL ¶
MarshalGQL @notice Writes the enum value into a GraphQL response.
func (*AuthLevel) UnmarshalGQL ¶
UnmarshalGQL @notice Parses the enum value from a GraphQL argument.
@return error when the value is not one of the two levels
type DirectiveOptions ¶
type DirectiveOptions struct {
// MFAWindow @notice How recently MFA must have been satisfied. Zero means
// DefaultMFAWindow.
MFAWindow time.Duration
// BypassRole @notice A role that satisfies any `roles` requirement. Empty means none —
// there is no implicit "admin".
BypassRole string
// Proofs @notice Satisfies all named zero-knowledge claims from request context. Nil
// denies every non-empty proves requirement, so an installed schema cannot silently run
// without its proof middleware.
Proofs func(ctx context.Context, claims []string) error
}
DirectiveOptions @notice Configuration for Directive. The zero value is the production posture.
type Event ¶ added in v0.4.0
type Event struct {
// Kind @notice What happened; see the list above.
Kind string
// UserID @notice The account, when there is one. Empty on a failed login: the address may not
// belong to an account at all, and resolving it to say so would be the enumeration channel the
// rest of authn is built to close.
UserID string
// Email @notice The address the caller supplied, already lowercased.
Email string
// IP @notice The client address as Config.ClientIP attributed it. Empty when unknown.
IP string
// At @notice When kal observed it, filled by [Audit.Emit] when zero.
At time.Time
// Detail @notice Event-specific context. May be nil; treat every key as optional.
Detail map[string]string
}
Event @notice One security-relevant thing kal did.
@dev A struct rather than an interface, and a flat one: every field is something a consumer writes to an audit row, and an event nobody can serialise without a type switch is an event nobody logs.
The vocabulary is open on purpose — Kind is a string, not an enum — because a closed set would make every new event a breaking change for a consumer switching on it exhaustively. The kinds kal emits today:
login.ok a password login succeeded login.fail a password login was refused; UserID is empty, the account may not exist backoff.open the per-account or per-address window rejected an attempt before hashing password.rehash rehash-on-login failed; the stored hash is still the weaker one mail.fail the Mailer refused a message kal wanted sent session.lookup.fail the session lookup failed on the driver; the request proceeded anonymous session.revoked one session, or every session of one user, was terminated
Three of those — password.rehash, mail.fail and session.lookup.fail — are emitted only on failure, because the sites they sit at are best-effort paths that log and continue. Their absence means nothing went wrong, not that nothing happened.
type Principal ¶
type Principal struct {
UserID string // never empty for an authenticated principal
SessionID string // the session this request authenticated with
Roles []string // read fresh from auth_user_roles by the session lookup
AuthAt time.Time // when the session was established — for absolute-age policies
MFAAt time.Time // zero if MFA was never satisfied on this session; drives step-up
Email string
Verified bool
}
Principal @notice The authenticated caller, as a resolver sees it.
@dev Everything on it is resolved once per request by the session middleware, so a directive or a resolver can check it without touching the database. That is deliberate: a directive on a field of a list type runs once per row, and an authorization check that costs a query is an N+1 invisible until production load.
func From ¶
From @notice Returns the caller, and whether there is one.
@dev Anonymous is not an error: one GraphQL endpoint serves public and private fields in the same document, so "no principal" is an ordinary state a resolver branches on, not a fault.
@param ctx the resolver context @return *Principal the caller, or nil @return bool whether a caller is present
func Require ¶
Require @notice Returns the caller, or a typed UNAUTHENTICATED error for the resolver to return.
@dev Two accessors and no MustFrom: a panicking accessor in an auth library turns a missing middleware into a 500 with a stack trace, where Require turns it into the UNAUTHENTICATED error that is both correct and what the client needs to react to.
@param ctx the resolver context @return *Principal the caller; nil exactly when error is non-nil @return error a *kalerr.Error with CodeUnauthenticated when there is no caller
type Roles ¶
type Roles struct {
// contains filtered or unexported fields
}
Roles @notice Grants and revokes role membership.
@dev A deliberately small surface: kal models roles as names on users and nothing more. There is no permission table, no hierarchy and no inheritance, because the moment a library ships those it has shipped a policy DSL — and the answer to "owner_id = me OR role = admin" is one WHERE clause, not an engine. Anything richer belongs in the consumer's own schema, reachable from a Scope closure.
func NewRoles ¶
NewRoles @notice Prepares the statements, optionally schema-qualified.
@param schema optional Postgres schema holding the auth_* tables @return *Roles ready for concurrent use @return error an invalid schema name
func (*Roles) Ensure ¶
Ensure @notice Creates the role if it does not exist. Idempotent, so it is safe to call at startup for every role an application knows about.
@param name the role name @param description free text for an administration UI @return error any driver error
func (*Roles) ForUser ¶
ForUser @notice The roles userID holds, sorted.
@return []string the role names; empty, never nil @return error any driver error
func (*Roles) Grant ¶
Grant @notice Gives userID the role. Idempotent.
@dev The role must exist: the foreign key is what stops a typo from creating a role nobody holds and a check nobody passes. That error surfaces as a driver error rather than a client-visible one, because granting roles is an administrative path, not a public one.
@return error any driver error, including a foreign-key violation for an unknown role