database

package
v8.0.0 Latest Latest
Warning

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

Go to latest
Published: Jul 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.

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.

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.SQLQueryExecutor) 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.

Upserts are lookup-then-write in batches of a hundred rather than a dialect-specific ON CONFLICT clause: three statements per batch regardless of size, no RETURNING, nothing that differs across the three dialects. A row is updated only when its description actually changed or it was archived, so a re-run of an unchanged policy writes nothing.

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 = "authz_"

DefaultTablePrefix is the prefix the policy tables carry when none is configured.

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" yaml:"dialect"`
	// TablePrefix is prepended to every policy table name. Empty uses
	// DefaultTablePrefix; set it to adopt tables that already exist under
	// another name.
	TablePrefix string `env:"TABLE_PREFIX" json:"tablePrefix" yaml:"tablePrefix"`
}

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.TracerProvider) 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.

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) 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
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