permission

package module
v0.4.2 Latest Latest
Warning

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

Go to latest
Published: Sep 18, 2026 License: MIT Imports: 22 Imported by: 0

README

Arandu

Arandu Permission

It administers who may do what, and decides nothing.

The authority over every action stays where it already was: a policy written in Go, reached through a Grant, refusing by default. What this package owns is the administration of that — named groups, the actions each group carries, the people in them, and the permissions given to one person in their own right — plus the middleware that puts the result on the acting subject before any policy runs.

There is no table of permissions and no way to make one.

Install

go get github.com/hyz-is/arandu-permission

Wire it

An Arandu application registers a module explicitly. There is no service provider, no container and no discovery, so these are the lines to paste into bootstrap/app.go and there are no others.

The import, with the other module imports:

import (
	permission "github.com/hyz-is/arandu-permission"
)

The construction, in Build, after the session store exists and before k.Register:

	permissionModule, err := permission.New(permission.Config{
		Tenant:  cfg.Auth.Tenant,
		Actions: append(myapp.Actions(), permission.Actions()...),
	}, db, sessions, csrf)
	if err != nil {
		return App{}, err
	}

And the registration, inside the k.Register(...) call already there:

		permissionModule,

Then, once, before the application serves:

aru migrate

This package owns four tables, which is why the migration step is not optional and why arandu.mod.toml says migrations = true.

Publish the views

This package carries the markup of its own pages and hands it over instead of rendering it from the inside, because a page you cannot edit is a page that says the wrong thing in your product.

Look at what would be written, then write it:

aru vendor:publish --tag=view
aru vendor:publish --tag=view --apply

Nothing is written without --apply. The preview lists every file as create, update, unchanged or conflict, and running the command a second time writes nothing. A file changed outside its arandu:begin custom markers is reported as a conflict and left alone; --force publishes over one, and even then what is inside the markers is carried forward.

The files land under resources/views/modules/permission/, and from that point they are yours. Nothing of this package is compiled beside them, so no view name is registered twice and no rule has to decide which of two files won — the consequence being that a view of this package that changes later does not reach a project that already published it.

Two steps are left to you, and they are left to you because a command that edited bootstrap/app.go behind your back is a command whose output nobody can explain. Compile what was written:

aru view:build

and import the directory it wrote into, with the other imports:

	_ "your/module/path/storage/framework/views/modules/permission"

Without that import the views are not in the binary, and the module refuses to boot rather than answering the first request that reaches one of them with a 500. The refusal names the view, the command and the import.

Configuration

field required meaning
Tenant yes the customer a visitor with no session is read as. From the application's configuration, never from the request.
Actions yes every action the application may grant. Its own code, spliced with permission.Actions(). What is not in it cannot be attached to anything.
Prefix no where the routes are mounted. Defaults to /permission.
PageSize no how many records one page answers with. Defaults to 25, refused above 200.
CacheSize no how many resolved subjects one process remembers. Defaults to 4096. It bounds memory and nothing else.
Translator no the application's own catalogue, asked before the sentences this package ships.
Listeners no told what changed, once it has changed.

New returns an error rather than starting half-wired, so a setting that cannot work fails where it is written instead of on the first request that needed it.

Routes

Under the configured prefix, /permission by default.

method path name requires
GET /groups permission.index permission.list
POST /groups permission.store permission.create
GET /groups/{group} permission.show permission.view
PUT /groups/{group} permission.update permission.update
DELETE /groups/{group} permission.destroy permission.delete
POST /groups/{group}/summary permission.summary permission.view
PUT /groups/{group}/actions permission.grant permission.grant
PUT /groups/{group}/members permission.assign permission.assign
GET /catalogue permission.catalogue permission.list
GET /matrix permission.matrix permission.list
GET /users permission.members permission.list
GET /users/{user} permission.member permission.view
POST /users/{user}/summary permission.member.summary permission.view
PUT /users/{user}/actions permission.member.grant permission.grant_direct

The declared action decides nothing: the handler still asks the service, the service still asks the policy, and the policy is still what refuses. It is there so that a screen listing what may be granted reads the router rather than a list beside it.

The first group

Nobody can administer permissions until somebody carries them, and the screen that creates groups is behind the permission the first group confers. So the first one comes from outside a request:

	_, err := permissionModule.Service().Bootstrap(ctx, cfg.Auth.Tenant, permission.BootstrapRequest{
		Slug:    "administrators",
		Name:    "Administrators",
		Members: []string{firstUserID},
	})

or from a terminal:

aru permission:create-group administrators Administrators --member=<user id>

It is refused the moment the tenant has a group, so there is no way back to it. Everything it writes goes through the same use cases a screen calls.

Two ways a permission reaches a person

Through a group, or given to them in their own right — what the reference calls an extra permission. Both are the same action from the same closed catalogue, both land in the same resolved set, and the same policy in Go decides with the result.

The direct grant is the shortest path from may administer permissions to may do anything, so it carries two refusals that the group path also carries and that no reference implementation has: nobody hands out what they do not hold, and nobody hands anything to themselves.

Selectors

An application with a hundred actions is a hundred checkboxes. A selector names many at once and is read where it is written:

	wanted, err := svc.Catalogue().Match("invoice.*")
selector names
invoice.create one action
invoice.create,update two actions of one module
invoice.* every action of the invoice module, at any depth
*.delete the delete of every module
invoice everything below invoice

Nothing stores a selector. What lands in a row is always a concrete action the catalogue holds, so no decision anywhere has to match a string against a pattern. A selector that names nothing is refused rather than answered with an empty list.

Commands

module.Commands() returns them; an application adds them to its own console.

command what it does
permission:show every group against every permission
permission:create-group a group, or the first group of an installation
permission:grant / permission:revoke permissions on a group, by name or selector
permission:assign / permission:unassign people in a group
permission:give permissions to one person in their own right
permission:cache-reset move the tenant's token, so every process re-reads

Every one of them takes --as, and what that person may do is read out of the database rather than asserted by whoever typed the command. A command can do what the person named could have done from the panel, and nothing more.

The route guard

	r.Group("", permissionModule.Require(myapp.ReportRead)).
		Get("/reports", reports.Index)

It refuses and it never admits. It has to run after the middleware that fills in what a subject may do. There is deliberately no counterpart taking group names: a group is where a permission came from, it is renamed by whoever administers it, and it is never checked against the catalogue.

Translations

The panel ships English and Brazilian Portuguese. The lines are embedded rather than published — a view is meant to be edited, a sentence is meant to keep up with the code that produces it.

An application overrides one by defining the same key in its own catalogue and handing that translator to Config.Translator. Its catalogue is asked first and this one is the floor under it, so what is not overridden keeps coming from here. permission.Lines(locale) is what there is to override.

The label of an action is derived from the action and resolved when the page is drawn. An action nobody has written a sentence for reads as its identifier, which is the better answer for an application's own actions: the identifier is what its developers named it.

Events

	Listeners: []permission.Listener{func(ctx context.Context, e permission.Event) {
		log.Printf("%s %s by %s: %v", e.Kind, e.GroupSlug, e.ActorID, e.Actions)
	}},

Seven kinds, covering every write that could change a decision. Each runs after the write has committed, on the path of the request that caused it.

Run it

go run -tags example ./example

Opens SQLite in a temporary directory and walks the whole of the above: the first group, a selector, both escalation refusals, a direct grant, who may do what and why, and the token moving on a revocation.

Model-first data path

Group embeds model.Model[Group], and Groups(db) is the one configured entry point for its table. PermissionService owns *data.DB and follows validate -> security.Authorize -> Grant -> Model terminal; handlers never hold the database or construct a Model.

CreateGroup writes TenantID from data.Tenant(g). FindGroup authorizes before reading and again against the row it found. ListGroups authorizes before building its scoped query. The Model keeps its default tenant_id scope on every terminal.

Terminals return *Group and []*Group. Keep those pointers intact: copying an embedded Model leaves its Entity pointer aimed at the original allocation.

There is no CRUD Repository. Add one only for a complex query, read model, report, export or raw SQL contract that the common Model path cannot express.

Layout

module.go       registration, routes, handlers and migrations
config.go       what the application passes in
catalogue.go    the closed set of actions, and the selectors that name them
model.go        the entities, and what they may answer with
policy.go       who may do what
service.go      the rules and authorized Model access
resolver.go     what a request carries into every policy, and the route guard
event.go        what the application is told, once it has happened
translation.go  the sentences a screen draws
command.go      the same use cases, from a terminal
views.go        the files the application takes ownership of

What is already correct, and has to stay that way

The policy denies everything. There is no permit-all branch to delete later. The Service calls security.Authorize before its first Groups(db) reach, and every Model terminal requires the Grant that call produced.

Authorization precedes the Model. The package audit checks that order in every exported Service method. A Model terminal enforces tenant scope; the preceding Policy call decides whether the action itself is allowed.

The tenant comes from data.Tenant(g). Never from the path, the body, the query string or a header. The value on the Grant came from the session; a value that arrived with the request is a value the caller chose.

arandu.mod.toml declares what the package does — network, filesystem, exec, migrations — and the suite compares the declaration against what the code calls, not against what it imports: net/http is imported by everything with a route and says nothing. A package that says it makes no outbound calls and then opens one fails its own tests, and that is the only place the comparison happens. aru doctor audits the application it is run inside and never loads a dependency, so nothing audits an installed package except the package itself.

Tests

go test -race ./...

The denial suite constructs the Service with a nil database, so even building Groups(nil) would panic. The structural twin reads the allowed path and rejects any Service method that reaches the Model before Authorize.

Licence

MIT. See LICENSE.md. Copyright Paulo R. Lima.

Documentation

Overview

Package permission administers who may do what, and decides nothing.

The authority over every action stays where it already was: a policy written in Go, reached through a Grant, refusing by default. What this package owns is the administration of that -- named groups, the actions each group carries, the people in them -- and the middleware that puts the result on the acting subject before any policy runs.

There is no table of permissions and there is no way to make one. The set of actions a group may carry is the application's own code, handed in as Config.Actions, and a write naming anything outside it is refused. A row here links a group to a permission that already exists because some policy reads it; it cannot bring one into being.

A permission reaches a person two ways -- through a group, or given to them in their own right. Both are the same action from the same closed catalogue, both land in the same resolved set, and the same policy in Go decides with the result. Two sources, one decision.

The files are laid out by role rather than by layer, so the whole package reads top to bottom:

module.go       -> registration, routes, handlers and migrations
config.go       -> what the application passes in
catalogue.go    -> the closed set of actions, and the selectors that name them
model.go        -> the entities, and what they may answer with
policy.go       -> who may do what
service.go      -> the rules and Model access, after authorization
resolver.go     -> what a request carries into every policy, and the route guard
event.go        -> what the application is told, once it has happened
translation.go  -> the sentences a screen draws
command.go      -> the same use cases, from a terminal
views.go        -> the files the application takes ownership of

An application registers it explicitly. There is no service provider, no container and no discovery: the wiring is a few lines somebody wrote, and reading them is how they learn what the application is made of.

The first group has to come from outside a request, because nobody can administer permissions until somebody carries them. PermissionService.Bootstrap is that door and it is shut by the state: it refuses as soon as the tenant has a group, so the moment there is somebody to authorize as, there is no way back to it. Everything it writes goes through the same use cases a screen calls.

Index

Constants

View Source
const (
	// SelectorSeparator divides a selector into parts, the way a dot divides an
	// action.
	SelectorSeparator = "."
	// SelectorAlternative divides one part into the alternatives it accepts, so
	// that "invoice.create,update" names two actions rather than one with a
	// comma in it.
	SelectorAlternative = ","
	// SelectorWildcard is the part that matches whatever is in that position.
	SelectorWildcard = "*"
)

The syntax of a selector.

They are named here because three things read them: the parser, the matcher, and the sentence a refusal is written with. A delimiter spelled twice is a selector that parses one way and is explained another.

View Source
const (
	// CommandShow prints the groups against the catalogue.
	CommandShow = "permission:show"
	// CommandCreateGroup adds a group, and adds the first one.
	CommandCreateGroup = "permission:create-group"
	// CommandGrant attaches permissions to a group.
	CommandGrant = "permission:grant"
	// CommandRevoke detaches them.
	CommandRevoke = "permission:revoke"
	// CommandAssign puts people in a group.
	CommandAssign = "permission:assign"
	// CommandUnassign takes them out.
	CommandUnassign = "permission:unassign"
	// CommandGive gives one person permissions in their own right.
	CommandGive = "permission:give"
	// CommandForget makes every process re-read what it remembers.
	CommandForget = "permission:cache-reset"
)

The commands this package answers to.

They are values in a slice an application splices into its own console, which is the same shape as everything else here: nothing is discovered, nothing registers itself, and what a binary can do is a list somebody wrote.

None of them is a way around the panel. Every one calls the same use case a screen calls, so the same policy answers, the same catalogue is checked and the same version token moves. What they add is a terminal, which is where the first group of an installation has to come from and where an operator with a list of a hundred permissions would rather be.

View Source
const (
	// DefaultPrefix is where the routes are mounted when Config leaves Prefix
	// empty.
	DefaultPrefix = "/permission"
	// DefaultPageSize is how many groups one page answers with when Config
	// leaves PageSize at zero.
	DefaultPageSize = 25
	// MaxPageSize is the ceiling PageSize is refused above. A page nobody
	// bounded is a page that reads the whole table on the day the table is
	// large.
	MaxPageSize = 200
	// DefaultCacheSize is how many resolved subjects one process remembers
	// before it forgets all of them.
	DefaultCacheSize = 4096
)

The defaults for the optional settings. They are constants rather than literals inside Config.withDefaults, so the value a reader finds here is the value the package uses.

View Source
const (
	// GroupsTable holds one row per group.
	GroupsTable = "permission_groups"
	// GroupActionsTable holds one row per action a group carries.
	GroupActionsTable = "permission_group_actions"
	// GroupUsersTable holds one row per person in a group.
	GroupUsersTable = "permission_group_users"
	// UserActionsTable holds one row per action a person carries outside every
	// group.
	UserActionsTable = "permission_user_actions"
	// VersionsTable holds one row per tenant, carrying the token that changes
	// whenever anything above does.
	VersionsTable = "permission_versions"
)

The tables this package owns.

They are named here once and read from here everywhere, so the migration, the models and anything that inspects the schema cannot drift into two spellings of one table.

View Source
const (
	// ViewGroupsIndex is the listing, with its search and its pages.
	ViewGroupsIndex = "modules.permission.groups.index"
	// ViewGroupsShow is one group: what it carries and who is in it.
	ViewGroupsShow = "modules.permission.groups.show"
	// ViewCatalogue is every action the application declares, by domain.
	ViewCatalogue = "modules.permission.catalogue.index"
	// ViewMatrix is the grid of groups against actions.
	ViewMatrix = "modules.permission.matrix.index"
	// ViewSummary is the fragment that says what a bulk write would change,
	// before it is applied.
	ViewSummary = "modules.permission.matrix.summary"
	// ViewMember is one person's effective permissions and where each comes
	// from.
	ViewMember = "modules.permission.users.show"
	// ViewMembers is the listing of everybody this module has written a row
	// about.
	ViewMembers = "modules.permission.users.index"
)

The names the published views are rendered by.

They are derived from the paths in the archive, and the archive is what the publication writes, so a view that moved cannot keep an old name here without the module refusing to boot.

View Source
const (
	// PermissionView is reading one group with the actions and members it
	// carries, and reading the effective permissions of one person.
	PermissionView security.Action = "permission.view"
	// PermissionList is paging through the groups and reading the catalogue.
	PermissionList security.Action = "permission.list"
	// PermissionCreate is adding a group.
	PermissionCreate security.Action = "permission.create"
	// PermissionUpdate is changing a group's name or description.
	PermissionUpdate security.Action = "permission.update"
	// PermissionDelete is removing a group.
	PermissionDelete security.Action = "permission.delete"
	// PermissionGrant is attaching an action to a group.
	PermissionGrant security.Action = "permission.grant"
	// PermissionRevoke is detaching an action from a group.
	PermissionRevoke security.Action = "permission.revoke"
	// PermissionAssign is putting a person into a group.
	PermissionAssign security.Action = "permission.assign"
	// PermissionUnassign is taking a person out of a group.
	PermissionUnassign security.Action = "permission.unassign"
	// PermissionGrantDirect is giving one person an action in their own right,
	// outside every group.
	//
	// It is separate from PermissionGrant because the two are different amounts
	// of power and an installation should be able to hand out one without the
	// other. Editing a group is a change somebody else can read off a screen
	// named after a role; giving one person one permission is a change nobody
	// goes looking for.
	PermissionGrantDirect security.Action = "permission.grant_direct"
	// PermissionRevokeDirect is taking such an action back.
	PermissionRevokeDirect security.Action = "permission.revoke_direct"

	// PermissionResolve is reading the groups one is a member of.
	//
	// It is decided by identity and not by membership: whoever asks may read
	// their own rows and nobody else's, so attaching it to a group would grant
	// nothing. That is why Actions leaves it out, and why leaving it out is not
	// an omission -- a screen offering a permission that changes nothing is a
	// screen nobody can reason about.
	PermissionResolve security.Action = "permission.resolve"
)

The actions of this package.

They are constants and never built from a variable: an action assembled at run time cannot be compared against the one a Grant was issued for, and it cannot be read out of the source by anything that enumerates what an application may grant.

They are the administration of the catalogue, and they are themselves part of it: whoever administers permissions holds a permission to do so, granted through a group like any other.

View Source
const (
	// MaxSlugLength is how long a slug may be. It is short because the slug is
	// an identifier somebody types.
	MaxSlugLength = 64
	// MaxNameLength and MaxDescriptionLength bound the free text.
	MaxNameLength        = 120
	MaxDescriptionLength = 500
	// MaxBulkSize is how many actions or members one bulk write may name. A
	// request that named a hundred thousand would be authorized a hundred
	// thousand times before anything was written.
	MaxBulkSize = 500
)

The bounds on what a group may be called.

View Source
const (
	// TranslationGroup is the catalogue group, so every key of this package
	// reads "permission." followed by what it names.
	TranslationGroup = "permission"
	// FallbackLocale is the locale a line is read from when the one asked for
	// has none.
	FallbackLocale = "en"
)

The catalogue this package's keys live in, and the locale every line is written in before anything else.

View Source
const (
	// ActionKeyPrefix turns an action into the key its label is read from, so
	// "invoice.delete" is read from "permission.action.invoice.delete".
	ActionKeyPrefix = TranslationGroup + ".action."
	// DomainKeyPrefix does the same for the part of an action before its first
	// dot.
	DomainKeyPrefix = TranslationGroup + ".domain."
)

The prefixes of the two key families that are derived from an identifier rather than written out.

They are constants because the derivation happens in two places -- the lookup and the catalogue file -- and a prefix spelled twice is a label that resolves in one of them.

View Source
const PublishCommand = "aru vendor:publish --apply"

PublishCommand is what an application runs to take ownership of the views this package offers.

It is spelled out as a constant so that whatever says it -- the refusal below, or a message an application writes for its own operators -- says one thing. A person who is told two different commands for one job tries both.

Variables

View Source
var (
	// ErrNotFound is returned when no row matches, including when the row
	// exists in another tenant. The two cases are deliberately
	// indistinguishable.
	ErrNotFound = errors.New("permission: record not found")

	// ErrUnknownAction is returned when an action is not in the catalogue. It
	// is what makes a group unable to carry a permission no policy reads.
	ErrUnknownAction = errors.New("permission: the action is not in the catalogue")

	// ErrLastMember is returned when taking somebody out of a system group
	// would leave it empty.
	//
	// It is not an authorization refusal and is deliberately not spelled as
	// one: the subject was allowed to do it, and the state it would leave
	// behind is what is refused. An application that answered it as 403 would
	// tell somebody to ask for a permission they already have.
	ErrLastMember = errors.New("permission: a system group cannot be left without a member")

	// ErrSlugTaken is returned when a group with that slug already exists in
	// the tenant.
	ErrSlugTaken = errors.New("permission: a group with this slug already exists")

	// ErrTooMany is returned when one write names more actions or more people
	// than a request may.
	//
	// It is the caller's fault and it is answered as such. It used to be an
	// unnamed error, which meant a client that sent one too many was told the
	// server had failed -- and a person who reads that sends it again.
	ErrTooMany = errors.New("permission: the write names more than one request may")

	// ErrInvalidMember is returned when somebody is named by an identifier that
	// cannot be one.
	ErrInvalidMember = errors.New("permission: a member was named by an identifier that cannot be one")

	// ErrBootstrapped is returned when the first group is asked for and the
	// tenant already has one.
	//
	// It is what closes the only door in this package that opens without a
	// subject behind it. The door exists because nobody can administer
	// permissions until somebody carries them; it shuts the moment somebody
	// does, and it never opens again.
	ErrBootstrapped = errors.New("permission: this tenant already has a group, so there is somebody to authorize as")
)

The refusals this package answers with.

They are distinguished from one another because they are answered with different statuses and because only one of them is the caller's fault.

View Source
var ErrNoMatch = errors.New("permission: the selector matches no action in the catalogue")

ErrNoMatch is returned when a selector names no action the catalogue holds.

The catalogue is finite and known before the selector is read, so a selector that matches nothing is a mistake rather than an empty result. Answering it as an empty list would let "invoce.*" take every invoice permission off a group and report success.

Functions

func Actions

func Actions() []security.Action

Actions are the actions of this package that a group may carry, sorted.

An application splices them into the catalogue it hands to New, so that the administration of permissions can itself be administered instead of being reachable only by whoever seeded the first group.

func GroupActions

func GroupActions(db *data.DB) *model.Model[GroupAction]

GroupActions returns the configured model for the group actions table.

It keeps no timestamps: the row is the link and nothing else, and a column that is written and never read is a column somebody eventually filters by.

func GroupUsers

func GroupUsers(db *data.DB) *model.Model[GroupUser]

GroupUsers returns the configured model for the group members table.

It keeps no timestamps, for the reason GroupActions keeps none.

func Groups

func Groups(db *data.DB) *model.Model[Group]

Groups returns the configured model for the groups table.

The primary key is application-generated text, so it does not increment. The tenant scope remains on the model's tenant_id default.

func Lines added in v0.2.0

func Lines(locale string) translation.Lines

Lines are the sentences this package ships for one locale, keyed the way a translator asks for them, and nil for a locale it does not ship.

It is exported so an application can load them into its own translator, look at what there is to override, or check a locale of its own against them.

func Locales added in v0.2.0

func Locales() []string

Locales are the locales this package ships sentences for, sorted.

An application reads it to know which of its own it has to write, and a test reads it to check that none of them is missing a line the others have.

func PublishedPaths

func PublishedPaths() []string

PublishedPaths are the files the archive offers, each at the path it is written at relative to the root of the application, sorted.

func UserActions added in v0.2.0

func UserActions(db *data.DB) *model.Model[UserAction]

UserActions returns the configured model for the direct grants table.

It keeps no timestamps, for the reason GroupActions keeps none.

func Versions

func Versions(db *data.DB) *model.Model[Version]

Versions returns the configured model for the version table.

Its key is the tenant column, which is the one place in this package where the two are the same: the table holds one row per customer, so the scope and the key are one question.

func ViewNames

func ViewNames() []string

ViewNames are the names the published views are rendered by, sorted.

The name is the path a view is written at, under the directory an application keeps its views in, with its separators turned into dots -- which is what the view compiler writes into the registration call. It is derived from the same archive the publication carries, so a view that was renamed cannot keep an old name here.

func ViewPackages

func ViewPackages() []string

ViewPackages are the directories the compiled views land in, each relative to the root of the application, sorted and without repeats.

Importing one is what puts its views in the binary: a compiled view calls the registry from init(), and a package nothing imports is not linked at all. The import is named rather than written into bootstrap/app.go, because one line somebody reads beats a file that changed while they were not looking.

Types

type ActionChoice

type ActionChoice struct {
	Action security.Action
	Held   bool
}

ActionChoice is one action on the group screen, and whether the group carries it.

type ActionPolicy

type ActionPolicy struct{}

ActionPolicy decides who may attach an action to a group and detach it again.

It sees the action being granted, which is what lets it hold the rule that matters most here: nobody hands out what they do not hold. Without it, whoever may edit any group may write themselves every permission the catalogue has, and every other check in this package passes while it happens.

func (ActionPolicy) Can

Can decides whether the subject may attach or detach this action.

type ActionSection

type ActionSection struct {
	// Domain is the part before the first dot, shared by every action below.
	Domain  string
	Choices []ActionChoice
}

ActionSection is one domain of the catalogue as the group screen draws it.

type BootstrapRequest added in v0.2.0

type BootstrapRequest struct {
	// Slug, Name and Description are the group's, as they are anywhere else.
	Slug        string
	Name        string
	Description string

	// Actions are what the group carries on top of this package's own, which it
	// always carries. Every one of them is checked against the catalogue.
	Actions []security.Action

	// Members are who is in it, and at least one is required: a system group
	// with nobody in it is refused everywhere else in this package, and there is
	// no reason for the one path that creates it to be able to produce one.
	Members []string
}

BootstrapRequest is the first group of an installation.

It is one request rather than three calls because the three are one thing: a group carrying the administration of permissions, with somebody in it. Any two of those without the third is a state nobody can act from.

func (BootstrapRequest) Validate added in v0.2.0

func (r BootstrapRequest) Validate() validation.Errors

Validate reports the errors per field.

type Catalogue

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

Catalogue is the set of actions a group may carry.

It is not a table and there is no way to add to it from a screen. A row that attached a group to an action nothing decides with would be a permission somebody was told they had, and no policy would ever read it -- so the write path checks every action against this set and refuses what is not in it.

The application builds it from what its own code declares and hands it to New. Nothing here discovers actions: a catalogue assembled at boot would hold only the modules that happened to be linked, and the screen administers permissions for the whole application.

The zero value is empty and refuses every action, which is the safe direction: a module wired without a catalogue grants nothing rather than everything.

func NewCatalogue

func NewCatalogue(actions ...security.Action) (Catalogue, error)

NewCatalogue returns the catalogue of these actions, or the reason it cannot be one.

It refuses an action that is not in module.verb form, because that shape is what the grouping reads and an action with no domain would sit in a section with no name. It refuses the empty set, because a module whose catalogue is empty can never grant anything and would say so one screen at a time instead of once, here.

A repeat inside the application's own list is not an error. The same action reaching it twice is what happens when an application splices several of its own lists together, and refusing that would make the caller deduplicate a set this function returns deduplicated anyway.

An action this package also declares is a different matter

The instruction is to splice: Actions: append(myapp.Actions(), permission.Actions()...). So one of this package's actions appearing twice means the application declared the same slug -- and the two are not the same authorization. This package's permission.create opens the screen that administers groups; an application's permission.create governs whatever its own policy governs. Collapsing them silently grants each through the other, so a person given the screen is given the application's write, and the other way round.

It is refused, naming the action, because which of the two renames is a decision only whoever wrote both lists can take.

func (Catalogue) All

func (c Catalogue) All() []security.Action

All returns every action, sorted. The slice is a copy, so a caller that sorts or truncates it does not change the catalogue every write is checked against.

func (Catalogue) Domains

func (c Catalogue) Domains() []Domain

Domains returns the catalogue grouped by domain, sorted. The slices are copies, for the reason All returns one.

func (Catalogue) Has

func (c Catalogue) Has(action security.Action) bool

Has reports whether the action may be attached to a group.

func (Catalogue) Len

func (c Catalogue) Len() int

Len is how many distinct actions the catalogue holds.

func (Catalogue) Match added in v0.2.0

func (c Catalogue) Match(selector string) ([]security.Action, error)

Match returns every action of the catalogue the selector names, sorted.

A selector is an action pattern: parts divided by dots, alternatives inside a part divided by commas, and a part that is a single asterisk matching whatever stands in that position.

invoice.create           one action, named outright
invoice.create,update    two actions of one module
invoice.*                every action of the invoice module
*.delete                 the delete of every module
invoice                  every action below invoice, however deep

A selector that runs out of parts covers everything below where it stopped, which is why "invoice" names the whole module -- and why a wildcard in the last position does the same: "invoice.*" names "invoice.line.create" as well as "invoice.create". A selector longer than an action does not match it, so "invoice.create.line" names nothing while "invoice.create" is all there is.

What comes back is always actions the catalogue holds. A selector cannot bring one into being, cannot widen past the set the application declared, and cannot be stored: it is read where a person writes it and what is written down is the actions it resolved to. That is the difference between naming many permissions at once and keeping a pattern somewhere a decision would later have to match against -- the second is a decision moved out of Go and into a string, and nothing here does it.

A selector that matches nothing is ErrNoMatch rather than an empty list.

type CatalogueDomain

type CatalogueDomain struct {
	// Name is the part of every action below it that comes before the first
	// dot.
	Name string
	// Entries are the actions of this domain, sorted.
	Entries []CatalogueEntry
}

CatalogueDomain is one section of the catalogue screen.

type CatalogueEntry

type CatalogueEntry struct {
	// Action is the identifier, and it is the identifier that is stored. What a
	// person reads is derived from it when the page is drawn, so a label can be
	// rewritten in any language without a row changing.
	Action security.Action
	// Groups are the groups that carry it, sorted by slug. Empty means the
	// action exists in the code and no group grants it.
	Groups []GroupRef
}

CatalogueEntry is one action of the catalogue and the groups that carry it.

type CataloguePageData

type CataloguePageData struct {
	hview.Page

	Prefix  string
	Labels  Labels
	Domains []CatalogueDomain
}

CataloguePageData is every action the application declares, by domain, and the groups that carry each.

type CatalogueView

type CatalogueView struct {
	// Domains are the sections, sorted by name.
	Domains []CatalogueDomain
}

CatalogueView is the catalogue as a screen draws it.

type Change

type Change struct {
	// Added and Removed are what the write puts in and takes out, sorted.
	// Unchanged is what was already as asked, which is what makes the summary
	// readable: a list of twelve permissions where two are changing says more
	// than a list of two.
	Added     []string
	Removed   []string
	Unchanged []string
}

Change is what a bulk write would do, or did.

It is returned by the preview and by the write, from the same computation, so the summary somebody approved is the summary that was applied rather than a second reading of the same intent.

func (Change) Empty

func (c Change) Empty() bool

Empty reports whether the change would write nothing.

type Config

type Config struct {
	// Tenant is the customer a visitor with no session is read as.
	//
	// It is required, and it comes from the application's own configuration --
	// never from the request. A tenant a visitor could name is a visitor who
	// chooses whose rows they read. Everywhere there is a session, the tenant
	// comes from the Grant instead, and this value is not consulted at all.
	Tenant string

	// Actions is every action the application may grant, and it is required.
	//
	// It is the application's own list, read out of its code, and it is handed
	// in rather than discovered: a set assembled at boot would hold only the
	// modules that happened to be linked into this binary, and the screen
	// administers permissions for the whole application.
	//
	// It has to contain this package's own actions, which Actions returns, or
	// the administration of permissions would be reachable only by whoever
	// seeded the first group. Splice them in:
	//
	//	Actions: append(myapp.Actions(), permission.Actions()...)
	//
	// Repeating one of the application's own actions costs nothing. Declaring
	// one of THIS package's is refused, because the two would not be the same
	// authorization: this package's permission.create opens the screen that
	// administers groups, and an application's governs whatever its own policy
	// governs. Collapsing them grants each through the other.
	//
	// What is not in it cannot be attached to a group, which is the whole of
	// why a screen cannot invent a permission.
	Actions []security.Action

	// Prefix is the path the routes are mounted under. Empty means
	// DefaultPrefix.
	Prefix string

	// PageSize is how many groups one page answers with. Zero means
	// DefaultPageSize, and anything above MaxPageSize is refused rather than
	// clamped: a number somebody wrote and did not get is worse than a number
	// somebody wrote and was told about.
	PageSize int

	// Translator is the application's own catalogue, asked before the one this
	// package ships.
	//
	// It is optional. Nil means the screens read the shipped sentences, which
	// is the right default for an application that has not translated anything
	// -- a panel in English beats a panel showing its own keys.
	//
	// An application that sets it overrides a sentence by defining the same key
	// in its own catalogue. Nothing has to be copied and nothing goes stale:
	// what is not overridden keeps coming from here, including lines added by a
	// later release. Lines returns what there is to override.
	Translator *translation.Translator

	// Listeners are told what changed, after it has changed.
	//
	// They are handed in here rather than registered afterwards, so that what
	// an application does when a permission moves is written at the one place
	// the module is wired and read there. An empty list is the ordinary case
	// and costs nothing: there is no flag to turn events on, because a flag
	// beside an empty list is two ways to say the same thing and only one of
	// them would be checked.
	//
	// Each one runs on the path of the request that caused the change. See
	// Listener for what that means.
	Listeners []Listener

	// CacheSize is how many resolved subjects one process remembers. Zero means
	// DefaultCacheSize. It bounds memory and nothing else: the remembered
	// answer is only used while the tenant's token is unchanged, so forgetting
	// early costs a query and can never serve a permission that was revoked.
	CacheSize int
}

Config is what the application passes when it wires this package.

A typed struct rather than a map: a misspelled key in a map is a setting that silently keeps its default, and the failure shows up as behaviour nobody asked for rather than as an error. Here a field that does not exist does not compile.

func (Config) Validate

func (c Config) Validate() error

Validate reports what the configuration cannot be used with.

It is called by New, so an application with a setting that cannot work fails where it is wired rather than on the first request that needed it.

type CreateGroupRequest

type CreateGroupRequest struct {
	// Slug is the stable identifier inside the tenant.
	Slug string
	// Name and Description are what people read.
	Name        string
	Description string

	// System declares a group the installation depends on: one that cannot be
	// deleted, cannot have actions taken off it, and cannot be left without a
	// member.
	//
	// It is here for whatever seeds an installation, and the request the HTTP
	// handler builds never sets it -- the handler reads three named fields and
	// this is not one of them. A form that could declare a group undeletable
	// would let anybody make one, and there would be no way back through this
	// package.
	System bool
}

CreateGroupRequest is the input contract for a new group.

The fields are explicit and there is no mass assignment, so a request body cannot write a column nobody meant to expose. There is no TenantID here and there must never be one: the tenant comes from the Grant, which comes from the session.

func (CreateGroupRequest) Validate

func (r CreateGroupRequest) Validate() validation.Errors

Validate reports the errors per field.

type Domain

type Domain struct {
	// Name is the part before the first dot, shared by every action below.
	Name string
	// Actions are the actions of this domain, sorted.
	Actions []security.Action
}

Domain is one group of the catalogue: the part of an action before its first dot, and every action that starts with it.

It is what a screen draws a section from. The name is the identifier and not a label: what a person reads is resolved when the page is rendered, so it can be written in their language without anything in the database changing.

type Effective

type Effective struct {
	// UserID is who this is about.
	UserID string
	// Grants are the actions they hold, sorted by action.
	Grants []EffectiveGrant
	// Groups are the groups they belong to, sorted by slug.
	Groups []GroupRef
	// Direct are the actions they carry in their own right, sorted. They are
	// also in Grants, marked as such; this is the list a screen edits.
	Direct []security.Action
}

Effective is what one person may do, and why.

func (Effective) Actions added in v0.3.0

func (e Effective) Actions() []security.Action

Actions is the flat list an authorization decision reads: one entry per distinct action, sorted.

It was called Roles and answered []string, because auth.Subject had one list of strings and Roles was its name. Writing actions into that field made HasRole answer about them -- so every policy in a consuming application that asked HasRole("admin") began answering false, silently, since both sides were []string. Subject carries Actions now, typed, and this is what fills it.

type EffectiveGrant

type EffectiveGrant struct {
	// Action is what the person may do.
	Action security.Action
	// Groups are the groups that grant it, sorted by slug.
	Groups []GroupRef
	// Direct is whether the person carries it in their own right, on top of
	// whatever the groups confer.
	Direct bool
}

EffectiveGrant is one action a person holds, and everything that gives it to them.

The origin is a list and not a single group, because two groups granting the same action is the normal case and the question a screen is asked is "why do they have this" -- which has as many answers as there are groups.

Direct is the answer that is not a group at all, and it is a separate field rather than a group with a made-up name: a screen that listed it beside the real ones would offer a link to a group that does not exist, and whoever wanted to take the permission away would go looking for it there.

type Event added in v0.2.0

type Event struct {
	// Kind is what happened.
	Kind EventKind
	// At is when, in UTC.
	At time.Time
	// Tenant is the customer it happened in. It comes from the Grant, like
	// every other tenant here.
	Tenant string
	// ActorID is who did it. It is empty only where nothing did -- there is no
	// path in this package that writes without a subject.
	ActorID string
	// Version is the tenant's token after the change, for the kinds that move
	// it. A listener that keeps its own copy of anything compares this rather
	// than a timestamp.
	Version int64

	// GroupID and GroupSlug name the group, on the kinds that have one. Both
	// are empty on an event about what one person carries in their own right.
	GroupID   string
	GroupSlug string
	// UserID names the person, on an event about their own grants. It is empty
	// on every kind about a group.
	UserID string

	// Actions are the actions attached or detached, sorted. Empty on every kind
	// that is not about permissions.
	Actions []security.Action
	// Members are the people attached or detached, sorted. Empty on every kind
	// that is not about membership.
	Members []string
}

Event is one thing that happened, told to whoever asked to be told.

It carries what a listener needs to write a line somebody can read a year later: who did it, what it was about, and what changed. It does not carry the record, because a record handed to a listener is a record a listener can save -- and a write nobody authorized is exactly what this package exists to make impossible.

type EventKind added in v0.2.0

type EventKind string

EventKind names what happened.

The set is closed and the values are constants, for the reason the actions are: a listener switches on one of these, and a kind assembled at run time is a branch nobody can find by reading the code.

The reference emits four -- a role attached, a role detached, a permission attached, a permission detached. Here a permission is attached to two different things, so the pair that carries it says which by what it fills in, and the three that report a group appearing, changing and going away are added because the reference gets them from its persistence layer and this package has no such layer to get them from.

const (
	// GroupCreated is a group that now exists.
	GroupCreated EventKind = "permission.group.created"
	// GroupUpdated is a change to what a group is called. It never changes what
	// anybody may do.
	GroupUpdated EventKind = "permission.group.updated"
	// GroupDeleted is a group that is gone, with everything it carried and
	// everybody it held.
	GroupDeleted EventKind = "permission.group.deleted"

	// ActionsAttached is permissions gained. GroupID names the group that
	// gained them, or UserID names the person who did.
	ActionsAttached EventKind = "permission.actions.attached"
	// ActionsDetached is permissions lost, addressed the same way.
	ActionsDetached EventKind = "permission.actions.detached"

	// MembersAttached is people who are now in a group.
	MembersAttached EventKind = "permission.members.attached"
	// MembersDetached is people who are no longer in one.
	MembersDetached EventKind = "permission.members.detached"
)

type Group

type Group struct {
	model.Model[Group]

	// ID is the identifier. It is generated by the application rather than by
	// the database, because a DEFAULT that produces a uuid is spelled
	// differently in every engine.
	ID string `db:"id"`

	// TenantID is the customer the row belongs to. It is written from the
	// Grant and read back so the policy can compare it against the subject's
	// tenant.
	TenantID string `db:"tenant_id"`

	// Slug is the stable, lowercase name of the group inside its tenant. It is
	// what a seed and a migration name a group by, and it does not change:
	// renaming it would silently create a second group beside whatever already
	// referred to the first.
	Slug string `db:"slug"`

	// Name is what a person calls this group, and Description is why it exists.
	// Both are free text, both are editable, and neither is ever compared
	// against anything.
	Name        string `db:"name"`
	Description string `db:"description"`

	// IsSystem marks a group the installation depends on. It cannot be deleted
	// and its actions cannot be detached, because it is the group that carries
	// the administration of permissions: an application that emptied it would
	// have nobody left who could fill it again.
	IsSystem bool `db:"is_system"`

	// CreatedAt and UpdatedAt are when the row was written and last changed, in
	// UTC.
	CreatedAt time.Time `db:"created_at"`
	UpdatedAt time.Time `db:"updated_at"`
}

Group is a named set of actions, and the people who carry them.

It embeds the model, so a row returned by a query carries the connection and can be saved again. Build new rows through Groups: a struct literal has no connection and its write methods return model.ErrUnwired.

type GroupAction

type GroupAction struct {
	model.Model[GroupAction]

	// ID is the identifier, generated by the application.
	ID string `db:"id"`
	// TenantID is the customer the row belongs to.
	TenantID string `db:"tenant_id"`
	// GroupID is the group this row attaches the action to.
	GroupID string `db:"group_id"`
	// Action is the action, in module.verb form, and it exists in the
	// catalogue: nothing writes this column without checking that first.
	Action string `db:"action"`
}

GroupAction is one action a group carries.

Action is a plain string and not the action type the policies take, because the column is read and written by the driver: a named type is one more thing between what the database holds and what compares it. It is converted at the two edges that care, and the conversion is checked against the catalogue before anything is written.

type GroupPage

type GroupPage struct {
	// Items are the groups, in slug order.
	Items []GroupRef
	// Next is what the next request passes back as a cursor, empty when this
	// page is the last one.
	Next string
}

GroupPage is a page of groups and where the next one resumes.

type GroupPageData

type GroupPageData struct {
	hview.Page

	Prefix string
	Labels Labels
	Group  GroupRef
	// System says the group cannot be deleted and cannot have actions taken
	// off it, so the screen draws those controls as unavailable rather than
	// offering a change the server refuses.
	System bool
	// Description is what the group is for.
	Description string
	// Sections are every action of the catalogue, by domain, each saying
	// whether this group carries it.
	Sections []ActionSection
	// Members are who is in the group.
	Members []string
}

GroupPageData is one group: what it carries, and who is in it.

type GroupPolicy

type GroupPolicy struct{}

GroupPolicy decides who may administer a group.

It is the only authority over a Group, and it decides from what the subject carries: the effective actions filled in before any policy runs. A subject that carries nothing is refused every action here, which is the state an application starts in and the state it returns to when the last group that granted these actions is emptied.

func (GroupPolicy) Can

Can decides whether the subject may perform the action on the group.

type GroupQuery

type GroupQuery struct {
	// Search narrows the listing to groups whose slug or name contains it.
	//
	// The characters the pattern syntax reserves are removed before the term is
	// used, so nothing typed into the box can become a wildcard. The statement
	// carries no escape clause, and one engine of the three reads a backslash
	// there as a literal backslash -- so escaping would mean a search that
	// behaves differently depending on where the application is deployed.
	Search string
	// Cursor is where this page resumes, taken from the previous page's Next.
	Cursor string
	// Limit is how many rows the page holds. Zero means the default, and
	// anything above the maximum is brought down to it.
	Limit int
}

GroupQuery is what a listing asks for.

type GroupRef

type GroupRef struct {
	// ID addresses the group, Slug identifies it and Name is what a person
	// reads.
	ID   string
	Slug string
	Name string
}

GroupRef is a group as something else refers to it: enough to name it on a screen, and no more.

type GroupUser

type GroupUser struct {
	model.Model[GroupUser]

	// ID is the identifier, generated by the application.
	ID string `db:"id"`
	// TenantID is the customer the row belongs to.
	TenantID string `db:"tenant_id"`
	// GroupID is the group, and UserID is who is in it.
	GroupID string `db:"group_id"`
	UserID  string `db:"user_id"`
}

GroupUser is one person in a group.

type GroupsPageData

type GroupsPageData struct {
	hview.Page

	// Prefix is where this module answers, so the markup composes its own
	// addresses instead of hard-coding one the configuration can change.
	Prefix string
	// Labels are the sentences this screen draws, resolved for the locale the
	// request asked for.
	Labels Labels
	// Search is the term the listing was narrowed by, echoed back into the
	// field so the box still says what is being looked at.
	Search string
	// Groups are the rows, and Next is the cursor of the following page, empty
	// on the last one.
	Groups []GroupRef
	Next   string
	// Rejected holds the field errors of a create that was refused, so the form
	// comes back with the reason on it rather than blank.
	Rejected validation.Errors
}

GroupsPageData is the listing.

type Labels added in v0.2.0

type Labels struct {
	// Locale is what these were resolved for.
	Locale string
	// contains filtered or unexported fields
}

Labels is what one screen reads, resolved for the locale the request asked for.

It is filled by the handler and handed to the view, which is the whole of why there is no helper a template calls for itself. A view that reached for a translator would be a view that can be rendered outside a request, in whatever locale the process happened to be left in -- and the failure looks like one person's page coming back in somebody else's language.

The zero value answers every key with the key, which is what a screen drawn by something that forgot to fill it in should look like: obviously unfinished, rather than quietly English.

func (Labels) Action added in v0.2.0

func (l Labels) Action(action security.Action) string

Action is what a person reads in place of an action.

The identifier is what is stored and what every decision is taken on; this is derived from it when the page is drawn. That is what lets a label be rewritten in any language, or corrected in this one, without a row changing and without anything that referenced the action breaking.

An action with no line reads as itself, which is the right answer rather than a fallback: the identifier is what the application's own developers named it, and it is what they will search for.

func (Labels) Domain added in v0.2.0

func (l Labels) Domain(name string) string

Domain is what a person reads in place of the part of an action before its first dot. It falls back to the domain itself, for the reason Action does.

func (Labels) T added in v0.2.0

func (l Labels) T(key string) string

T is the sentence at this key, which is written without the group: T("field.name") reads "permission.field.name".

A key with no line anywhere comes back as itself. It is the one answer that cannot be mistaken for a translation, which is what somebody staring at a screen needs in order to find the missing line.

type Listener added in v0.2.0

type Listener func(context.Context, Event)

Listener is something told what happened.

It is called after the write has committed, in the goroutine that made it, and what it does is on the path of the request that caused it. A listener that talks to something slow makes the screen slow; one that has to do that hands the work to a queue and returns.

It returns nothing, and that is the contract rather than an omission. The write is already durable by the time it is called, so there is no failure a listener could report that anything could still act on -- and an error that travelled back to the caller would report a write that succeeded as one that did not.

type MatrixCell

type MatrixCell struct {
	Action security.Action
	Held   bool
}

MatrixCell is one square of the matrix: an action, and whether the row's group carries it.

type MatrixPageData

type MatrixPageData struct {
	hview.Page

	Prefix string
	Labels Labels
	Search string
	Matrix MatrixView
}

MatrixPageData is the grid of groups against actions.

type MatrixRow

type MatrixRow struct {
	Group GroupRef
	// System marks a group whose actions cannot be detached, so a screen can
	// draw the squares as fixed rather than offering a change that is refused.
	System bool
	Cells  []MatrixCell
}

MatrixRow is one group of the matrix and its squares, in the same order as the matrix's actions.

type MatrixView

type MatrixView struct {
	// Actions are the columns, sorted, and Domains is the same set grouped so
	// that a screen can draw a heading over each run of columns.
	Actions []security.Action
	Domains []Domain
	// Rows are the groups, in slug order, and Next is where the following page
	// resumes.
	Rows []MatrixRow
	Next string
}

MatrixView is the group by permission grid.

type MemberPage added in v0.2.0

type MemberPage struct {
	// Items are the people, in identifier order.
	Items []MemberRef
	// Next is what the next request passes back as a cursor, empty when this
	// page is the last one.
	Next string
}

MemberPage is a page of people and where the next one resumes.

type MemberPageData

type MemberPageData struct {
	hview.Page

	Prefix    string
	Labels    Labels
	Effective Effective
	// Sections are every action of the catalogue, by domain, each saying
	// whether this person carries it in their own right.
	//
	// What a group confers is not ticked here. The box edits one table, and a
	// box that came back ticked because a group granted the action would be a
	// box somebody unticks expecting the permission to go away.
	Sections []ActionSection
}

MemberPageData is one person's effective permissions and where each of them comes from.

type MemberQuery added in v0.2.0

type MemberQuery struct {
	// Group narrows the listing to one group, by slug. Empty is everybody.
	Group string
	// Cursor is where this page resumes, taken from the previous page's Next.
	Cursor string
	// Limit is how many people the page holds. Zero means the default, and
	// anything above the maximum is brought down to it.
	Limit int
}

MemberQuery is what a listing of people asks for.

type MemberRef added in v0.2.0

type MemberRef struct {
	// UserID is who this is about.
	UserID string
	// Groups are the groups they are in, sorted by slug.
	Groups []GroupRef
	// Direct is how many permissions they carry in their own right. It is a
	// count and not the list, because the listing is one line per person and a
	// person with forty of them would be forty lines.
	Direct int
}

MemberRef is one person as a listing names them: who they are, what puts them there, and nothing else.

There is no name and no address on it, and there cannot be. This package does not own the table people are in -- it owns rows about them -- so a listing here answers "who has this module written something about", which is a different question from "who are the users" and is the only one it can answer honestly. An application that wants names joins these identifiers to its own table.

type MembersPageData added in v0.2.0

type MembersPageData struct {
	hview.Page

	Prefix string
	Labels Labels
	// Search is the identifier search echoed into the query control.
	Search string
	// Group is the slug the listing was narrowed by, echoed back so the control
	// still says what is being looked at.
	Group string
	// Groups are every group of the tenant, for the control that narrows by one.
	Groups []GroupRef
	// Members are the rows, and Next is the cursor of the following page.
	Members []MemberRef
	Next    string
}

MembersPageData is the listing of everybody this module has written a row about.

type MembershipPolicy

type MembershipPolicy struct{}

MembershipPolicy decides who may put a person into a group, take them out, and read the groups they belong to.

It sees who the row is about, which is what lets it refuse the shortest path to more power there is: adding yourself to a group. Everything else in this package would allow it -- the group is legitimate, the actions on it were granted by somebody who held them, and the person doing it may administer groups.

func (MembershipPolicy) Can

Can decides whether the subject may change or read this membership.

type Module

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

Module is what the application registers.

It implements foundation.Module, which is Name and Routes and nothing else -- that pair is the whole public contract between a package and the framework.

It also implements foundation.Migratable, because it owns tables, and foundation.Publishable, because it hands view sources to the project.

func New

func New(cfg Config, db *data.DB, sessions *security.SessionStore, csrf *security.CSRF) (*Module, error)

New returns the module, or the reason it cannot be built.

The collaborators are parameters and not fields somebody fills in afterwards: a module that could be registered half-wired is a module whose first request is the thing that reports the missing half.

It returns an error rather than panicking or carrying on, because everything it refuses is a wiring mistake, and a wiring mistake found at boot costs one restart. The same mistake found later is a request that reached a nil handle.

func (*Module) Boot

func (m *Module) Boot(context.Context) error

Boot refuses to serve when a view this package renders is not in the binary.

A compiled view registers itself from init(), so by the time anything boots the question has one answer already: either the application published the files, compiled them and imported the package they became, or it did not. Asking here turns "did anybody run the install command" into one refusal at start-up that names the views and the command, instead of a 500 on the first request that reached one of them.

It also holds the destination. Every file the archive offers has to land under the vendor directory named after this module: an archive that reached resources/views/home.kyse.go would land on a page the application wrote, and what publishes the files writes what the archive says.

func (*Module) Commands added in v0.2.0

func (m *Module) Commands() []console.Command

Commands returns this package's commands, sorted by name.

They are added to an application's console explicitly:

console.NewApplication(os.Stdout, os.Stderr, os.Stdin).Add(module.Commands()...)

Every one of them that writes takes --as, which is the identifier of the person it acts as. That person's permissions are read out of the database and are what the command may do -- so a command cannot do what the person named could not have done from the panel, and an operator who wants more asks for more the same way anybody else does.

The one exception is the first group, and it is an exception to whether there is anybody to act as rather than to whether the rules apply. See Bootstrap.

func (*Module) Labels added in v0.2.0

func (m *Module) Labels(locale string) Labels

Labels are the sentences of this panel in one locale.

It is exported because it is what a handler outside this package needs in order to draw the same words -- an application that wraps a screen of its own around these, or replaces one, reads its labels from here rather than writing a second copy of them.

An empty locale is answered in the shipped one. A request that went through no negotiation middleware carries none, and a panel in the wrong language is still a panel somebody can read, where a refusal would not be.

func (*Module) Migrations

func (m *Module) Migrations() []foundation.Migration

Migrations declares the schema this module owns.

They are returned in the order their names sort in, which is the order they apply in: the name carries the order, and nothing else decides it.

func (*Module) Name

func (m *Module) Name() string

Name is the module identifier: a lowercase slug, stable, no spaces.

It is what route listings group by and what the route names are prefixed with, so changing it changes addresses that other code has already written down.

func (*Module) Publishes

func (m *Module) Publishes() []foundation.Publication

Publishes declares the files this package offers, each at the path it takes relative to the root of the project.

One tree, under one tag. A publication may carry a page, a component, a configuration file, a migration, a catalogue of sentences or an asset, and this package offers the first of those and nothing else. Each absence is a decision rather than an omission:

  • configuration is the Config struct New is handed, checked by the compiler and validated before the module exists. A file copied into the project beside it would be a second place to say the same thing, and only one of the two could be the one the code reads.
  • a stylesheet, a script or any other asset is registered with the view layer and served from an address derived from its own bytes. Copying one into the project would put a second copy of those bytes under a second address, and a page can only reference one of them.
  • translations are overridden by writing the lines the application wants into its own vendor tree, which the catalogue loader already reads. A copy of every line this package ships is a copy that goes stale, and it goes stale without saying so.
  • migrations are declared and collected, never copied. A copy in the project's own migration directory is found by the runner as well, so one schema change applies twice under two names.

What is left is the markup, and it is here for the reason the others are not: it is the one thing the project is expected to edit. A package cannot know what a screen should say in a product it has never seen.

func (*Module) Require added in v0.2.0

func (m *Module) Require(actions ...security.Action) fhttp.Middleware

Require returns middleware that refuses a request unless the acting subject carries one of these actions.

It refuses and it never admits. Nothing it lets through has been authorized by it: the handler still asks the service, the service still asks the policy, and the policy is still what decides. What it buys is that a request which was never going to be allowed is answered before the handler runs -- before a row is read, before a page is composed, and with one rule written at the route instead of the same rule repeated in every handler behind it.

The actions are alternatives. A subject carrying any one of them passes, which is what a screen reachable by two different roles needs; a route that wants two at once is two calls, and reads as the conjunction it is.

It has to run after Roles, which is what puts the actions on the subject. Before it, every subject carries nothing and this refuses everybody -- which is the direction a missing step has to fail in, and still the wrong answer.

There is deliberately no counterpart taking group names. A group is not a permission: it is where a permission came from, it is renamed by whoever administers it, and it is never checked against the catalogue. A rule written against one would be a decision nothing in this package validated, taken on a string somebody can edit on a screen. What a route wants when it reaches for a role name is the permission that role carries, and that is what this takes.

func (*Module) Roles

func (m *Module) Roles() fhttp.Middleware

Roles returns middleware that fills in what the acting subject may do.

Mount it after whatever establishes the session and before anything that consults a policy. Every policy in the application reads the actions it leaves on the subject, so a request that skipped it is a request where every policy sees a subject carrying nothing -- which refuses rather than admits, and is the direction a missing step has to fail in.

It carries the subject on the request's context. A handler reads it with auth.SubjectFrom, and one that loads the session itself instead gets the subject as it was stored, without the actions -- so the two are not interchangeable, and this is the one that has them.

A request with no session passes through untouched: there is nobody to resolve, and answering a visitor with a refusal here would refuse them the public pages too.

func (*Module) Routes

func (m *Module) Routes(r *fhttp.Router)

Routes registers the module's routes under the configured prefix.

They are named, so a URL is built from a name rather than written out a second time somewhere else -- two spellings of one address disagree, and the failure when they do is a link to a 404.

Each one declares the action it requires. The declaration decides nothing: the handler still asks the service, the service still asks the policy, and the policy is still what refuses. What it is for is the catalogue -- a screen that lists what may be granted reads the router rather than a list beside it, and a list beside it is wrong the first time somebody adds a route.

The whole group runs behind the middleware that fills in what the subject may do, so the panel works whether or not the application mounted it globally. Mounting it twice costs nothing: the second pass finds the answer the first one remembered.

func (*Module) Service added in v0.2.0

func (m *Module) Service() *PermissionService

Service returns the use cases this module is built on.

It is exported because an application needs them outside a request: a seed creates the first group, a migration back-fills a tenant, a job reconciles what an external directory says. Every one of those is the same use case a screen calls, and reaching it here is what keeps them from being written a second time against the tables.

It is not a way past anything. Every method on it authorizes, and none of them takes a Grant -- so the caller supplies a subject and a policy decides, which is exactly what happens when the caller is a handler.

type PermissionService

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

PermissionService holds the rules of this package.

It receives its collaborators through the constructor. There is no container and no resolution by reflection: what this service is made of is written at the one place that builds it, and reading that place is how somebody learns what the package touches.

Everything a handler is allowed to do goes through here. The service is the only owner of the database handle, so the request layer cannot reach a Model before a policy has answered.

func NewPermissionService

func NewPermissionService(db *data.DB, catalogue Catalogue, listeners ...Listener) *PermissionService

NewPermissionService wires the service over the application's database handle and the catalogue its code declares.

The listeners are variadic and last, so a caller that wants none writes nothing rather than nil.

func (*PermissionService) ActionsOf

func (s *PermissionService) ActionsOf(ctx context.Context, actor security.Subject, groupID string) ([]security.Action, error)

ActionsOf returns the actions one group carries, sorted.

func (*PermissionService) Bootstrap added in v0.2.0

func (s *PermissionService) Bootstrap(ctx context.Context, tenant string, in BootstrapRequest) (*Group, error)

Bootstrap creates the first group of a tenant, and refuses once there is one.

It is the only door in this package that opens without somebody already carrying a permission, and it exists because there is a moment when nobody can: an installation with no group has nobody who may create one, and the screen that creates them is behind the permission the first group is meant to confer.

The door is not a hole. It is shut by the state rather than by a flag: the first thing it does after authorizing is ask whether the tenant has any group at all, and one is enough to refuse. From then on every write goes through a subject somebody's session produced, and there is no way back to here.

The subject it acts as is constructed, and is what an installation would otherwise have hand-rolled in a seed. Doing it here rather than in every project is what makes the guard exist at all: a subject written by hand in a seed script has no such check on it and stays runnable forever.

Everything it writes goes through the same use cases a screen calls, so the same policies answer, the same catalogue is checked and the same version token moves. It is not a second write path.

func (*PermissionService) Catalogue

func (s *PermissionService) Catalogue() Catalogue

Catalogue returns the closed set of actions a group may carry.

It is the value the service was built with and it never changes: what a screen may offer and what a write may store are one set, read from one place.

func (*PermissionService) CreateGroup

func (s *PermissionService) CreateGroup(ctx context.Context, actor security.Subject, in CreateGroupRequest) (*Group, error)

CreateGroup adds a group.

The candidate is authorized before it is stored, and the candidate is what the policy sees -- so a rule about what may be created is a rule about the group being created, and not about the person alone.

func (*PermissionService) DeleteGroup

func (s *PermissionService) DeleteGroup(ctx context.Context, actor security.Subject, id string) error

DeleteGroup removes a group, the actions it carried and the memberships it held.

The three deletions and the version bump are one transaction. A group whose row is gone while its memberships survive is a group that grants nothing and cannot be found to be repaired.

func (*PermissionService) DirectActionsOf added in v0.2.0

func (s *PermissionService) DirectActionsOf(ctx context.Context, actor security.Subject, userID string) ([]security.Action, error)

DirectActionsOf returns the actions one person carries in their own right, sorted.

They are what the reference calls extra permissions: what somebody holds on top of whatever their groups confer. A screen draws them beside the groups rather than mixed into them, because the two are undone in different places.

func (*PermissionService) EffectiveFor

func (s *PermissionService) EffectiveFor(ctx context.Context, actor security.Subject, userID string) (Effective, error)

EffectiveFor returns what one person may do, and which group gives them each of it.

The origin is the answer to the only question anybody asks a permissions screen twice: not what somebody has, but why. A list without it sends the person reading it through every group by hand.

func (*PermissionService) FindGroup

func (s *PermissionService) FindGroup(ctx context.Context, actor security.Subject, id string) (*Group, error)

FindGroup returns one group, and asks the policy twice.

The first call is on the empty candidate, because there is no way to read the row without a Grant and no way to hold a Grant without a decision. What it decides is whether this subject may view groups at all.

The second call is on the row that came back, and it is the one a rule about the record itself depends on: the first call saw an empty value, so anything the policy says about which tenant owns the row never ran. The read is already scoped by tenant, so the second call is not what keeps customers apart. It is what keeps the policy honest.

func (*PermissionService) ListGroups

func (s *PermissionService) ListGroups(ctx context.Context, actor security.Subject, q GroupQuery) (GroupPage, error)

ListGroups returns a page of groups.

It authorizes once, on the empty candidate, and the tenant filter in the statement is what bounds the rows. A policy call per row would be one call per record on a page and would still not narrow the query -- a listing that has to read a customer's rows in order to decide it may not read them has already read them.

func (*PermissionService) ListMembers added in v0.2.0

func (s *PermissionService) ListMembers(ctx context.Context, actor security.Subject, q MemberQuery) (MemberPage, error)

ListMembers returns a page of the people this module has written a row about.

It is not a list of the application's users, and it cannot be: this package does not own that table. It answers "who is in a group, or carries something in their own right", which is the set an administrator is actually looking through when they open a permissions panel.

Two tables carry that set, and neither is authoritative on its own. They are read side by side, and the page stops at the point beyond which one of them might still have something the other does not -- so a page never claims to be complete past what both reads reached. The cost is that a page can come back shorter than the limit with more to follow, which is why Next is what says whether there is more rather than the count of what came back.

func (*PermissionService) MembersOf

func (s *PermissionService) MembersOf(ctx context.Context, actor security.Subject, groupID string) ([]string, error)

MembersOf returns who is in one group, sorted.

func (*PermissionService) PreviewActions

func (s *PermissionService) PreviewActions(ctx context.Context, actor security.Subject, groupID string, wanted []security.Action) (Change, error)

PreviewActions reports what SetActions would change, and writes nothing.

It exists so that a screen can show the difference before it is applied, from the same computation the write uses. A summary produced by a second reading of the same intent is a summary that can disagree with what happens next.

func (*PermissionService) PreviewDirectActions added in v0.2.0

func (s *PermissionService) PreviewDirectActions(ctx context.Context, actor security.Subject, userID string, wanted []security.Action) (Change, error)

PreviewDirectActions reports what SetDirectActions would change, and writes nothing.

func (*PermissionService) PreviewMembers

func (s *PermissionService) PreviewMembers(ctx context.Context, actor security.Subject, groupID string, wanted []string) (Change, error)

PreviewMembers reports what SetMembers would change, and writes nothing.

func (*PermissionService) Refresh added in v0.2.0

func (s *PermissionService) Refresh(ctx context.Context, actor security.Subject) (int64, error)

Refresh moves the tenant's token without changing anything anybody may do, and returns the new value.

It is what makes every process re-read on its next request. The reference clears a shared cache; there is nothing shared here to clear, so the instruction is carried by the one value every process already consults -- and it reaches replicas nothing has a way to talk to.

It is not what makes revocation correct. Every write moves the token already, and a deployment that needed this in order to be safe would be a deployment with a write path that forgot to. It is for the operator who changed a row by hand.

It asks for the permission to change a group rather than to read one: it writes, and something that writes should not be reachable by whoever may only look.

func (*PermissionService) ResolveOwn

func (s *PermissionService) ResolveOwn(ctx context.Context, actor security.Subject) (Resolution, error)

ResolveOwn returns the effective actions of the subject asking.

It is what fills in the actions every other policy reads, so it cannot itself depend on them: the policy admits a subject asking about its own rows and refuses every other question. That is the whole of the exception, and it is an exception to which rule applies rather than to whether one does -- the read is authorized, it is scoped by the tenant on the Grant, and it answers about one person.

func (*PermissionService) SetActions

func (s *PermissionService) SetActions(ctx context.Context, actor security.Subject, groupID string, wanted []security.Action) (Change, error)

SetActions makes a group carry exactly these actions.

It is the only way an action is attached or detached, and that is deliberate: a per-action call beside it would be a second spelling of the same write, and the two would need the same authorization, the same catalogue check and the same version bump written twice. Attaching one action is this call with one more in the list.

Every added action is authorized on its own, so the rule that nobody grants what they do not hold is asked once per action rather than once per request.

func (*PermissionService) SetDirectActions added in v0.2.0

func (s *PermissionService) SetDirectActions(ctx context.Context, actor security.Subject, userID string, wanted []security.Action) (Change, error)

SetDirectActions makes one person carry exactly these actions in their own right.

It is the only way such a row is written, for the reason SetActions is the only way a group carries one: a per-action call beside it would need the same authorization, the same catalogue check and the same version bump written twice.

Every added action is authorized on its own. That is where the two rules that matter are asked -- nobody hands out what they do not hold, and nobody hands anything to themselves -- and asking them once per action rather than once per request is what makes a request naming twenty permissions no weaker than twenty requests naming one.

func (*PermissionService) SetMembers

func (s *PermissionService) SetMembers(ctx context.Context, actor security.Subject, groupID string, wanted []string) (Change, error)

SetMembers makes a group hold exactly these people.

Emptying a system group is refused, and it is refused here rather than in a policy because the policy is handed one row and the question is about how many are left. It is not an authorization refusal: the subject was allowed to do it, and what is refused is the state it would leave behind -- an application whose only group carrying the administration of permissions has nobody in it, and therefore nobody who could put anybody back.

func (*PermissionService) UpdateGroup

func (s *PermissionService) UpdateGroup(ctx context.Context, actor security.Subject, id string, in UpdateGroupRequest) (*Group, error)

UpdateGroup changes what a group is called.

It does not change what the group carries. Nothing about the name or the description reaches an authorization decision, so this path never touches the version token: a rename is not a permission change and invalidating every resolved subject for one would be a stampede for nothing.

func (*PermissionService) Version

func (s *PermissionService) Version(ctx context.Context, actor security.Subject) (int64, error)

Version returns the tenant's permission token.

It changes whenever anything that could change a decision changes, and it is compared for equality: a remembered resolution is still the answer while the token it was read at is still the token.

func (*PermissionService) ViewCatalogue

func (s *PermissionService) ViewCatalogue(ctx context.Context, actor security.Subject) (CatalogueView, error)

ViewCatalogue returns the catalogue grouped by domain, with the groups that carry each action.

func (*PermissionService) ViewMatrix

func (s *PermissionService) ViewMatrix(ctx context.Context, actor security.Subject, q GroupQuery) (MatrixView, error)

ViewMatrix returns a page of groups against every action of the catalogue.

type Resolution

type Resolution struct {
	// Version is the tenant's token at the moment the actions were read. It is
	// compared for equality against the current one to decide whether a
	// remembered answer is still the answer.
	Version int64
	// Actions are the effective actions, one entry per distinct action, sorted.
	Actions []security.Action
	// Groups are the groups the subject belongs to, sorted by slug. They name
	// the origin on a screen and are never read as authorization: a group is
	// not a permission, and a decision taken on a group name would be a
	// decision the catalogue never checked.
	Groups []GroupRef
}

Resolution is what a request carries into every policy that runs after it: the effective actions of the acting subject, and the token they were read at.

type Resolver

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

Resolver answers what the acting subject may do, and remembers the answer only while the tenant's token says it is still the answer.

The expensive half of the question -- the memberships, the groups, the actions those groups carry, and the assembly of the three -- is what is remembered. The cheap half, the tenant's token, is read every time, because what a remembered answer costs is the window between a permission being taken away and the person losing it. A remembered answer that outlives a revocation is the defect this whole mechanism exists to have none of, and a time-based expiry is exactly that defect with a number attached.

The memory is this process's. Another replica that changes a permission changes the token, and every process reads the token before it trusts what it remembers, so nothing has to be told about anything.

func NewResolver

func NewResolver(service *PermissionService, limit int) *Resolver

NewResolver wires a resolver over the service, remembering at most limit subjects. A limit of zero or less means DefaultCacheSize.

func (*Resolver) Forget

func (r *Resolver) Forget()

Forget drops every remembered answer.

It exists for the process that has just changed permissions and wants the next request in the same process to see them without waiting for anything. It is not what makes revocation correct -- the token is -- and nothing else calls it.

func (*Resolver) Resolve

func (r *Resolver) Resolve(ctx context.Context, subject security.Subject) ([]security.Action, error)

Resolve returns the effective actions of the subject.

It authorizes like everything else here: the subject asks about its own rows and the policy admits exactly that. A subject with no identifier is answered with nothing rather than with a query, because there is nobody to answer about.

type SummaryPageData

type SummaryPageData struct {
	// Prefix is where this module answers.
	Prefix string
	// Labels are the sentences this fragment draws.
	Labels Labels
	// Target is the address the confirmed write is sent to, composed by the
	// handler that drew the summary.
	//
	// It is here rather than assembled in the markup because two screens are
	// summarised by one fragment, and an address built from a branch inside the
	// template is an address that is wrong for whichever branch nobody tested.
	Target string
	// Group is the group the change is about, empty when the change is about
	// one person's own grants.
	Group GroupRef
	// Kind is "actions" or "members", which is what decides where the
	// confirmation goes and what the counts are called.
	Kind string
	// Change is the difference, from the same computation the write uses.
	Change Change
	// Fields are the values the confirmation resubmits, so that approving the
	// summary sends the same request that produced it.
	Fields []string
}

SummaryPageData is what a bulk write would change, drawn before it is applied.

It is a fragment: it extends no layout, because it is swapped into a page that already has one.

type UpdateGroupRequest

type UpdateGroupRequest struct {
	// Name and Description are what people read.
	Name        string
	Description string
}

UpdateGroupRequest is what may be changed about an existing group.

The slug is absent on purpose. It is what a seed, a fixture and an operator name a group by, and changing it would leave every one of those pointing at nothing while the group carried on working for everybody else.

func (UpdateGroupRequest) Validate

func (r UpdateGroupRequest) Validate() validation.Errors

Validate reports the errors per field.

type UserAction added in v0.2.0

type UserAction struct {
	model.Model[UserAction]

	// ID is the identifier, generated by the application.
	ID string `db:"id"`
	// TenantID is the customer the row belongs to.
	TenantID string `db:"tenant_id"`
	// UserID is who carries it.
	UserID string `db:"user_id"`
	// Action is the action, in module.verb form, and it exists in the
	// catalogue: nothing writes this column without checking that first.
	Action string `db:"action"`
}

UserAction is one action a person carries in their own right.

It is the grant that belongs to nobody else: a group is the answer to "what do people like this do", and this is the answer to "and this one person also does that". The reference calls them extra permissions, and the word is right -- they are what a person holds on top of whatever their groups confer.

It is not a second way to authorize. The action is checked against the same catalogue, it lands in the same resolved set the middleware puts on the subject, and the same policy in Go decides with it. What it adds is one more place the set is read from, before anything decides anything.

It is the shortest path to more power there is, which is why the policy that answers about it refuses the two moves that matter: handing out what you do not hold, and handing anything to yourself.

type UserActionPolicy added in v0.2.0

type UserActionPolicy struct{}

UserActionPolicy decides who may give one person an action in their own right.

It is the policy with the most to refuse, because a direct grant is the shortest path from "may administer permissions" to "may do anything". A group has to be created, named and looked at by somebody; a row here is one person quietly holding one more permission.

It holds the same two rules the group path holds, and it holds them harder: nobody hands out what they do not hold, and nobody hands anything to themselves. The second is not a nicety -- without it, whoever may administer permissions may write themselves every action in the catalogue in one request, and every other check in this package passes while it happens.

func (UserActionPolicy) Can added in v0.2.0

Can decides whether the subject may give this action to this person, take it back, or read what they carry.

type Version

type Version struct {
	model.Model[Version]

	// TenantID is the customer, and the primary key.
	TenantID string `db:"tenant_id"`
	// Version is the token.
	Version int64 `db:"version"`
}

Version is the token that changes whenever a tenant's permissions do.

One row per tenant, keyed by the tenant, so reading it is a point read on the primary key and writing it cannot produce a second row for the same customer.

The value is compared for equality and never ordered. It is not a count of changes and nothing may read it as one: two changes at once have to produce two different tokens, and a counter incremented from the same starting value twice produces one.

Jump to

Keyboard shortcuts

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