authz

package
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: MIT Imports: 16 Imported by: 0

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

View Source
const DefaultMFAWindow = 15 * time.Minute

DefaultMFAWindow @notice How recently MFA must have been satisfied for @auth(mfa: true).

View Source
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

func AssertDirectivesWired(directiveRoot any) error

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

func HasRole(ctx context.Context, role string) bool

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

func Scope(ctx context.Context, column string) func(*orm.Query) *orm.Query

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

func WithBypassRole(ctx context.Context, role string) context.Context

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

func WithPrincipal(ctx context.Context, p *Principal) context.Context

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

func WithRLS(ctx context.Context, db *pg.DB, fn func(orm.DB) error) error

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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

type Audit func(context.Context, Event)

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

func (a Audit) Emit(ctx context.Context, e Event)

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

func (l AuthLevel) MarshalGQL(w io.Writer)

MarshalGQL @notice Writes the enum value into a GraphQL response.

func (*AuthLevel) UnmarshalGQL

func (l *AuthLevel) UnmarshalGQL(v any) error

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

func From(ctx context.Context) (*Principal, bool)

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

func Require(ctx context.Context) (*Principal, error)

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

func NewRoles(schema string) (*Roles, error)

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

func (r *Roles) Ensure(ctx context.Context, db orm.DB, name, description string) error

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

func (r *Roles) ForUser(ctx context.Context, db orm.DB, userID string) ([]string, error)

ForUser @notice The roles userID holds, sorted.

@return []string the role names; empty, never nil @return error any driver error

func (*Roles) Grant

func (r *Roles) Grant(ctx context.Context, db orm.DB, userID, role string) error

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

func (*Roles) Revoke

func (r *Roles) Revoke(ctx context.Context, db orm.DB, userID, role string) error

Revoke @notice Takes the role away. Idempotent, and effective on the holder's next request — session lookup reads roles fresh rather than trusting what login recorded.

@return error any driver error

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL