database

package
v13.0.0 Latest Latest
Warning

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

Go to latest
Published: Aug 30, 2026 License: AGPL-3.0 Imports: 17 Imported by: 0

Documentation

Overview

Package database stores authorization policy in SQL tables.

Reach for it when roles must be editable data — when an operator defines a new role, or changes what an existing one grants, without shipping a release. If the roles are fixed at build time, authorization/static answers the same questions with no database and no migrations.

ddl, err := migrations.SQL(dialect.Postgres, database.DefaultTablePrefix)
// ... apply via database/migrate's WithGeneratedMigration

resolver, err := database.NewResolver(
	&database.Config{Dialect: dialect.Postgres},
	client.Reader(),
	database.WithLogger(logger),
	database.WithTracerProvider(tracerProvider),
)

What it owns

Four tables: roles, permissions, the grants between them, and the inheritance edges. Role *assignments* are deliberately absent. An assignment references the consumer's own users and tenants, and a platform package cannot model those without owning them too — so this package answers "what does this role grant" and the consumer answers "which roles does this principal hold".

That split is also what makes caching worthwhile. Policy is keyed by role name, so a deployment with five roles has five hot entries shared by every principal; had this package owned assignments, the cache key would be the principal and the hit rate would collapse. Wrap it in authorization/cached.

Where the SQL comes from

Every statement this package runs is in a committed corpus. The tables and their columns are declared as data in internal/queries, rendered through database/querygen into one .sql per dialect, checked by sqlc against the schema migrations renders — with no database running — and executed through the querier sqlc-gen-unison emits from those same files. A column renamed in a migration is a failed generate rather than a runtime scan error, on every table and in all three dialects.

What that replaced was thirteen statements built with fmt.Sprintf, the table prefix interpolated and the bind markers numbered by hand. Nothing checked any of it against the schema until a container test ran it, and hand-numbered markers fail in a characteristic way: correct on Postgres, where the marker carries its own number, and silently wrong on MySQL and SQLite, where it does not.

Resolution

One query. A recursive CTE walks each named role up to its ancestors, then joins through to permissions. Postgres, MySQL 8.0+, and SQLite all support it.

It uses UNION rather than UNION ALL, which is what makes it terminate if the hierarchy ever contains a cycle. Seed and UpsertRole reject cycles before they can be written, but a table edited by hand has no such guard, and a query that returns a slightly surprising union is a far better failure than one that hangs.

Archived roles and permissions are excluded at every join rather than only at write time, so archiving a permission revokes it everywhere on the next resolution without touching a single mapping row.

Neither property is the caller's to get wrong, and neither is visible in a diff of the SQL. Both are rendered unconditionally by querygen.Generator.ClosureQuery — the shape this query is the only instance of — so a corpus carrying a resolution with one and not the other is not something anybody can write.

Writing policy

Seed takes the same []authorization.Role that static.NewResolver takes. One declaration, either compiled in or written to the database — which is what keeps a code-side policy from drifting away from a database-side one, the failure this backend would otherwise invite.

Writes take the caller's executor rather than holding their own, following outbox.Writer.Enqueue, so a policy change commits with whatever else its transaction did:

err := client.WithTransaction(ctx, func(q database.Tx) error {
	return resolver.Seed(ctx, q, roles...)
})

Seed is idempotent, validates the whole policy before writing anything, and leaves roles it was not given alone — so it can run on every deploy without clobbering roles an operator added. Within a role it clears and rewrites, so removing a permission from a role's list actually revokes it.

Writing a role or a permission is one lookup for the whole batch and then a converging write for each row that is actually missing or actually different. A re-run of an unchanged policy writes nothing at all, so the tables and their indexes are not churned — and an audit trail on them stays worth reading.

The lookup is what supplies the id, and it has to. The write converges on the name, and only Postgres could hand back the id of the row it converged on: an existing name is therefore written under the id it already carries, and only a name nothing was found for is minted one. Binding a fresh id for a name already taken would leave the caller holding an id no row has, since MySQL resolves the collision on whichever unique key it hit.

Grants and inheritance edges are cleared and rewritten a row at a time. The multi-row VALUES list that preceded them had no static text — its arity was the caller's cardinality — so there was nothing for sqlc to check; what replaces it costs a round trip per grant, inside the transaction the caller already opened.

Archival

ArchiveRole soft-deletes, and the name stays reserved.

A principal may still hold an assignment naming an archived role; resolution simply stops finding it, so the assignment decays to granting nothing. Freeing the name for reuse would instead re-grant whatever a new role of that name holds to everyone still carrying the old assignment — a quiet privilege escalation that would look like a naming coincidence.

Index

Constants

View Source
const DefaultTablePrefix = ""

DefaultTablePrefix is the namespace the policy tables carry when none is configured, which is none — rendering authz_roles, authz_permissions, and the two join tables.

The authz_ segment is the schema's, not the caller's: a table always says which package created it. Setting a namespace of "ddb" renders ddb_authz_roles, for a database shared between applications. A namespace must not end in '_'; database/ddl supplies the separator.

Variables

View Source
var (
	// ErrInvalidTablePrefix indicates a prefix that is not a plain SQL
	// identifier fragment. Prefixes are interpolated into queries rather than
	// bound, so they are restricted rather than escaped.
	ErrInvalidTablePrefix = platformerrors.New("invalid authorization table prefix")
	// ErrNilExecutor indicates a query executor was required and not supplied.
	// It wraps errors.ErrNilInputParameter, so a caller may check either.
	ErrNilExecutor = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil query executor")
)

Functions

This section is empty.

Types

type Config

type Config struct {
	// Dialect selects the SQL emitted. Required.
	Dialect dialect.Dialect `env:"DIALECT" json:"dialect,omitempty" yaml:"dialect,omitempty"`
	// TablePrefix is the namespace prepended to every policy table name. Empty
	// renders the schema's own names (authz_roles); set it to share a database
	// between applications, which renders e.g. ddb_authz_roles. It must not end
	// in '_' — the separator is supplied for you.
	TablePrefix string `env:"TABLE_PREFIX" json:"tablePrefix,omitempty" yaml:"tablePrefix,omitempty"`
}

Config configures a Resolver.

type Option

type Option func(*Resolver)

Option configures a Resolver.

func WithLogger

func WithLogger(logger logging.Logger) Option

WithLogger attaches a logger.

func WithMetricsProvider

func WithMetricsProvider(metricsProvider metrics.Provider) Option

WithMetricsProvider attaches a metrics provider.

func WithTracerProvider

func WithTracerProvider(tracerProvider tracing.Provider) Option

WithTracerProvider attaches a tracer provider, so a policy resolution shows up as a child of the span that triggered it.

type Resolver

type Resolver struct {
	// contains filtered or unexported fields
}

Resolver resolves role names against policy stored in SQL tables.

Use it when roles themselves must be editable data — when an operator has to define a new role, or change what an existing one grants, without shipping a release. If the roles are fixed at build time, authorization/static answers the same questions with no database.

Resolution is one query. It is nonetheless worth wrapping in authorization/cached: policy changes rarely and there are usually a handful of roles, so a cache keyed by role names has a hit rate near one and is shared across every principal, rather than per-principal.

func NewResolver

func NewResolver(cfg *Config, db database.SQLQueryExecutor, opts ...Option) (*Resolver, error)

NewResolver builds a Resolver. The executor is used for reads; writes take the caller's executor per call so that a policy change commits with whatever else its transaction did.

func (*Resolver) ArchiveRole

func (r *Resolver) ArchiveRole(ctx context.Context, q database.SQLQueryExecutor, name string) error

ArchiveRole soft-deletes a role.

Archival rather than deletion, and the name stays reserved: a principal may still hold an assignment naming this role, and resolution simply stops finding it — the assignment decays to granting nothing. Freeing the name for reuse would instead re-grant whatever the new role holds to everyone who still carried the old assignment.

The row count is deliberately unread. Archiving a role that is already archived, or one that was never there, is a no-op rather than an error: the caller asked for the role to grant nothing and it grants nothing.

func (*Resolver) PermissionsForRoles

func (r *Resolver) PermissionsForRoles(ctx context.Context, roles ...string) (*authorization.PermissionSet, error)

PermissionsForRoles resolves the named roles, expanding inheritance in SQL.

func (*Resolver) Roles

func (r *Resolver) Roles(ctx context.Context) ([]authorization.Role, error)

Roles returns the policy as declared: each role with its direct permissions and its declared parents, not its resolved closure.

func (*Resolver) Seed

Seed writes roles into the policy tables, using the caller's executor so the whole policy lands in one transaction or not at all.

It is the counterpart to handing the same []authorization.Role to authorization/static: one declaration, either compiled in or written to the database. That is what keeps a code-side policy and a database-side policy from drifting, which is the failure this backend would otherwise invite.

Seed is idempotent. It upserts by name, rewrites each named role's direct permissions and parents, and leaves roles it was not given alone — so it can be run on every deploy without clobbering roles an operator added.

func (*Resolver) TablePrefix

func (r *Resolver) TablePrefix() string

TablePrefix returns the namespace this resolver's tables carry, for a caller that needs the rendered names — a maintenance TRUNCATE, a schema audit. Pass it to migrations.Statements.

func (*Resolver) UpsertRole

UpsertRole writes a single role. It validates the role against the policy already in the database, so a parent that does not exist — or an inheritance cycle the new edge would close — is rejected rather than written.

signature; a pointer here would make the two ways of writing policy differ for no benefit at a call rate of one per administrative action.

Directories

Path Synopsis
internal
queries
Package queries is the authorization policy schema described as data: the four canonical table names, the column list the two named tables share, and the statements the resolver runs over them.
Package queries is the authorization policy schema described as data: the four canonical table names, the column list the two named tables share, and the statements the resolver runs over them.
queriesgen command
Command queriesgen writes the canonical sqlc input for the authorization policy schema, one file per dialect it serves, from authorization/database/internal/queries.
Command queriesgen writes the canonical sqlc input for the authorization policy schema, one file per dialect it serves, from authorization/database/internal/queries.
Package migrations supplies the authorization policy tables' DDL, rendered for a dialect and table prefix.
Package migrations supplies the authorization policy tables' DDL, rendered for a dialect and table prefix.

Jump to

Keyboard shortcuts

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