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
- Variables
- type Config
- type Option
- type Resolver
- func (r *Resolver) ArchiveRole(ctx context.Context, q database.SQLQueryExecutor, name string) error
- func (r *Resolver) PermissionsForRoles(ctx context.Context, roles ...string) (*authorization.PermissionSet, error)
- func (r *Resolver) Roles(ctx context.Context) ([]authorization.Role, error)
- func (r *Resolver) Seed(ctx context.Context, q database.SQLQueryExecutor, roles ...authorization.Role) error
- func (r *Resolver) TablePrefix() string
- func (r *Resolver) UpsertRole(ctx context.Context, q database.SQLQueryExecutor, role authorization.Role) error
Constants ¶
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 ¶
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 WithMetricsProvider ¶
WithMetricsProvider attaches a metrics provider.
func WithTracerProvider ¶
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 ¶
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 ¶
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 ¶
Roles returns the policy as declared: each role with its direct permissions and its declared parents, not its resolved closure.
func (*Resolver) Seed ¶
func (r *Resolver) Seed(ctx context.Context, q database.SQLQueryExecutor, roles ...authorization.Role) error
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 ¶
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 ¶
func (r *Resolver) UpsertRole(ctx context.Context, q database.SQLQueryExecutor, role authorization.Role) error
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. |