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
- Variables
- func Actions() []security.Action
- func GroupActions(db *data.DB) *model.Model[GroupAction]
- func GroupUsers(db *data.DB) *model.Model[GroupUser]
- func Groups(db *data.DB) *model.Model[Group]
- func Lines(locale string) translation.Lines
- func Locales() []string
- func PublishedPaths() []string
- func UserActions(db *data.DB) *model.Model[UserAction]
- func Versions(db *data.DB) *model.Model[Version]
- func ViewNames() []string
- func ViewPackages() []string
- type ActionChoice
- type ActionPolicy
- type ActionSection
- type BootstrapRequest
- type Catalogue
- type CatalogueDomain
- type CatalogueEntry
- type CataloguePageData
- type CatalogueView
- type Change
- type Config
- type CreateGroupRequest
- type Domain
- type Effective
- type EffectiveGrant
- type Event
- type EventKind
- type Group
- type GroupAction
- type GroupPage
- type GroupPageData
- type GroupPolicy
- type GroupQuery
- type GroupRef
- type GroupUser
- type GroupsPageData
- type Labels
- type Listener
- type MatrixCell
- type MatrixPageData
- type MatrixRow
- type MatrixView
- type MemberPage
- type MemberPageData
- type MemberQuery
- type MemberRef
- type MembersPageData
- type MembershipPolicy
- type Module
- func (m *Module) Boot(context.Context) error
- func (m *Module) Commands() []console.Command
- func (m *Module) Labels(locale string) Labels
- func (m *Module) Migrations() []foundation.Migration
- func (m *Module) Name() string
- func (m *Module) Publishes() []foundation.Publication
- func (m *Module) Require(actions ...security.Action) fhttp.Middleware
- func (m *Module) Roles() fhttp.Middleware
- func (m *Module) Routes(r *fhttp.Router)
- func (m *Module) Service() *PermissionService
- type PermissionService
- func (s *PermissionService) ActionsOf(ctx context.Context, actor security.Subject, groupID string) ([]security.Action, error)
- func (s *PermissionService) Bootstrap(ctx context.Context, tenant string, in BootstrapRequest) (*Group, error)
- func (s *PermissionService) Catalogue() Catalogue
- func (s *PermissionService) CreateGroup(ctx context.Context, actor security.Subject, in CreateGroupRequest) (*Group, error)
- func (s *PermissionService) DeleteGroup(ctx context.Context, actor security.Subject, id string) error
- func (s *PermissionService) DirectActionsOf(ctx context.Context, actor security.Subject, userID string) ([]security.Action, error)
- func (s *PermissionService) EffectiveFor(ctx context.Context, actor security.Subject, userID string) (Effective, error)
- func (s *PermissionService) FindGroup(ctx context.Context, actor security.Subject, id string) (*Group, error)
- func (s *PermissionService) ListGroups(ctx context.Context, actor security.Subject, q GroupQuery) (GroupPage, error)
- func (s *PermissionService) ListMembers(ctx context.Context, actor security.Subject, q MemberQuery) (MemberPage, error)
- func (s *PermissionService) MembersOf(ctx context.Context, actor security.Subject, groupID string) ([]string, error)
- func (s *PermissionService) PreviewActions(ctx context.Context, actor security.Subject, groupID string, ...) (Change, error)
- func (s *PermissionService) PreviewDirectActions(ctx context.Context, actor security.Subject, userID string, ...) (Change, error)
- func (s *PermissionService) PreviewMembers(ctx context.Context, actor security.Subject, groupID string, wanted []string) (Change, error)
- func (s *PermissionService) Refresh(ctx context.Context, actor security.Subject) (int64, error)
- func (s *PermissionService) ResolveOwn(ctx context.Context, actor security.Subject) (Resolution, error)
- func (s *PermissionService) SetActions(ctx context.Context, actor security.Subject, groupID string, ...) (Change, error)
- func (s *PermissionService) SetDirectActions(ctx context.Context, actor security.Subject, userID string, ...) (Change, error)
- func (s *PermissionService) SetMembers(ctx context.Context, actor security.Subject, groupID string, wanted []string) (Change, error)
- func (s *PermissionService) UpdateGroup(ctx context.Context, actor security.Subject, id string, in UpdateGroupRequest) (*Group, error)
- func (s *PermissionService) Version(ctx context.Context, actor security.Subject) (int64, error)
- func (s *PermissionService) ViewCatalogue(ctx context.Context, actor security.Subject) (CatalogueView, error)
- func (s *PermissionService) ViewMatrix(ctx context.Context, actor security.Subject, q GroupQuery) (MatrixView, error)
- type Resolution
- type Resolver
- type SummaryPageData
- type UpdateGroupRequest
- type UserAction
- type UserActionPolicy
- type Version
Constants ¶
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 ¶
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.
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 ¶
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 ¶
GroupUsers returns the configured model for the group members table.
It keeps no timestamps, for the reason GroupActions keeps none.
func Groups ¶
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 ¶
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 ¶
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.
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 ¶
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 ¶
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 ¶
Domains returns the catalogue grouped by domain, sorted. The slices are copies, for the reason All returns one.
func (Catalogue) Match ¶ added in v0.2.0
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.
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.
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
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.
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
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
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
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
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 ¶
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.
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 ¶
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
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
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 ¶
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 ¶
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
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 ¶
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.
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.