Documentation
¶
Overview ¶
Package access decides whether an actor may do something.
This is the one place authorisation happens (rule 2, ADR-0005). Not in an adapter, not in a repository, not in a middleware: a check in an adapter covers the channel it sits in, and the same use case reached through MCP or through an automation rule would then be checked by nobody. It also has to be one place for the audit trail's sake - a refusal is recorded here, which is what makes `outcome=DENIED` complete without every developer having to remember it (audit.md §7).
Index ¶
- type ContainerReader
- type GrantReader
- type GroupMembers
- type HeldRefusals
- type ItemReader
- type ItemSubject
- type Loss
- type Permitter
- type Reach
- type Request
- type Revocations
- func (r Revocations) AfterContainerMoved(ctx context.Context, from, to work.Container) (Loss, error)
- func (r Revocations) AfterGrantRevoked(ctx context.Context, grant identity.Grant) (Loss, error)
- func (r Revocations) AfterGroupLoss(ctx context.Context, groupID shared.ID, accounts []shared.ID) ([]Loss, error)
- func (r Revocations) AfterHubMadePrivate(ctx context.Context, hub work.Container) (Loss, error)
- func (r Revocations) Announce(ctx context.Context, tenantID shared.ID, losses ...Loss) error
- type Service
- func (s Service) Authorize(ctx context.Context, actor appshared.ActorContext, request Request) error
- func (s Service) AuthorizeOwnPrivateHub(ctx context.Context, actor appshared.ActorContext, request Request) error
- func (s Service) CanSee(ctx context.Context, actor appshared.ActorContext, accountID shared.ID, ...) (bool, error)
- func (s Service) GrantableRoles(ctx context.Context, actor appshared.ActorContext, request Request) ([]identity.Role, error)
- func (s Service) HeldOnWorkspace(ctx context.Context, actor appshared.ActorContext) ([]service.Permission, error)
- func (s Service) Hidden(ctx context.Context, actor appshared.ActorContext, path []identity.Scope) (bool, error)
- func (s Service) MayOwnPrivateHub(ctx context.Context, actor appshared.ActorContext) (bool, error)
- func (s Service) Permits(ctx context.Context, actor appshared.ActorContext, request Request) (bool, error)
- func (s Service) Permitted(ctx context.Context, actor appshared.ActorContext, request Request, ...) ([]bool, error)
- func (s Service) ReachInto(ctx context.Context, actor appshared.ActorContext, request Request, ...) (Reach, error)
- func (s Service) RoleAlong(ctx context.Context, actor appshared.ActorContext, path []identity.Scope) (identity.Role, bool, error)
- func (s Service) RoleOf(ctx context.Context, actor appshared.ActorContext, accountID shared.ID, ...) (identity.Role, bool, error)
- func (s Service) WritesOnlyWhatIsAssigned(ctx context.Context, actor appshared.ActorContext, path []identity.Scope) (bool, error)
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type ContainerReader ¶
type ContainerReader interface {
Find(ctx context.Context, id shared.ID) (work.Container, error)
List(ctx context.Context, query workrepo.ContainerQuery) (workrepo.ContainerPage, error)
}
The slices of the repositories this reads - named so that a test fakes four methods rather than thirty, and so that the file says what it touches.
type GrantReader ¶
type GrantReader interface {
ListAt(ctx context.Context, scope identity.Scope, page identityrepo.Page) (identityrepo.GrantPage, error)
OfGroup(ctx context.Context, groupID shared.ID) ([]identity.Grant, error)
}
The slices of the repositories this reads - named so that a test fakes four methods rather than thirty, and so that the file says what it touches.
type GroupMembers ¶
type GroupMembers interface {
Members(ctx context.Context, groupID shared.ID) ([]shared.ID, error)
}
The slices of the repositories this reads - named so that a test fakes four methods rather than thirty, and so that the file says what it touches.
type HeldRefusals ¶
type HeldRefusals struct {
// contains filtered or unexported fields
}
HeldRefusals carries the DENIED entries recorded under a caller's read-write transaction until that transaction has ended, and then writes them.
Authorize's rule is to be asked before the caller's transaction. A use case that performs other use cases inside its own transaction - accepting a proposal applies it through the write use case - cannot keep it for the use cases it calls: their refusal is recorded inside the outer transaction, which that very refusal rolls back. Writing the entry beside it instead is no way out, because the outer transaction may hold the audit chain's lock and the second one would wait for its own caller (persistence.Writable). So the entry waits, and is written once nothing holds that lock.
func HoldRefusals ¶
func HoldRefusals(ctx context.Context) (context.Context, *HeldRefusals)
HoldRefusals answers a context under which the authoriser holds back the refusals it records, and the holder that writes them. The caller runs its transaction under the returned context and calls Record after the transaction has returned, whether it committed or not.
func (*HeldRefusals) Record ¶
func (h *HeldRefusals) Record(ctx context.Context)
Record writes the held entries, each in a transaction of its own. It is detached from the request's cancellation: the refusal has been answered already, and a client that hung up on it does not take the trail's entry with it.
type ItemReader ¶
The slices of the repositories this reads - named so that a test fakes four methods rather than thirty, and so that the file says what it touches.
type ItemSubject ¶
type ItemSubject struct {
// Does is the kind of access. Empty means this is not a request about a single entry.
Does service.ItemAction
// ID is the entry. Zero for a creation: there is nothing yet to share or to assign, so the
// path ends at the container the entry would be created under.
ID shared.ID
// Assignee is who the entry belongs to, zero for nobody. It is what "assigned only" is
// measured against, and it is read from the stored entry rather than from the request that
// wants to change it - a caller that could name the assignee would be naming its own
// permission.
Assignee shared.ID
}
ItemSubject is what the per-entry half of the role matrix needs in order to be applied: the entry, what the request does to it, and whose it is (domain-model.md §3.2).
The use case fills it in; the use case does not decide from it. It has already read the entry - the path to it is what the check is about - so naming it here costs nothing, while reading it again in this package would be a round trip for a row already in hand. Every judgement made about what is named here is made below, in one place (ADR-0005).
type Loss ¶
Loss is one removal as the use case that made it describes it: the accounts that held something through what was removed, and the scope it was held at.
type Permitter ¶
type Permitter interface {
Permits(ctx context.Context, actor appshared.ActorContext, request Request) (bool, error)
}
Permitter answers a permission question without recording it - Service.Permits, or a fake.
type Reach ¶
type Reach struct {
// All is true when a role held on the container's own path answers for every entry in it -
// which is the ordinary case, and the one that costs nothing extra.
All bool
// when All is false. Never empty when All is false: an actor who reaches neither the container
// nor anything in it has already been refused rather than handed an empty answer.
Shared []shared.ID
}
Reach is how much of a container's entries an actor may see.
type Request ¶
type Request struct {
// Permission is what is being asked for.
Permission service.Permission
// Alternative is a second permission that satisfies the request on its own, and empty where
// there is none (A-4).
//
// It exists for the configuration *reads* and for nothing else. Their first permission is the
// writing one somebody who administers the workspace holds; the second is the read-only one an
// auditor holds, and answering the request by either is what lets the auditor read what the
// workspace is configured to do without gaining the right to change it.
//
// The refusal names the permission that was *asked for* rather than the pair. A caller told
// "you need READ_CONFIGURATION" for a call every administrator makes through STRUCTURE would
// be sent to fix the wrong thing.
Alternative service.Permission
// Path runs from the tenant downwards. It decides which memberships count.
Path []identity.Scope
// Action is the audit code of the operation being attempted, e.g. `container.created`. A
// refusal is recorded against the action that was refused, not against a generic "denied".
Action audit.Action
// TokenScope is the second, independent bound (ADR-0005): the role may allow it and the
// token still may not. Empty means the operation needs no particular scope.
TokenScope string
TargetType string
TargetID shared.ID
// On names the entry the request is about, when it is about one. Zero means the request is
// about a container and the permission alone decides it.
On ItemSubject
// Grants is the role the request places or takes away, empty where it touches none. The actor
// must then also hold a role along the path at least as strong (service.MayGrant):
// MANAGE_MEMBERS alone would let an administrator make somebody an owner, or unmake one.
// A revocation and a change of who is in a group name it as a grant does - one rule, one name.
Grants identity.Role
}
Request is one permission question, plus what the trail needs in order to record the answer if it is no.
There is no target label. A container's name is user content, and rule 10 keeps user content out of the audit trail; the name reaches the entry as a fingerprint through the `changes` field, which is enough to compare two entries and not enough to read one.
type Revocations ¶
type Revocations struct {
Permits Permitter
Grants GrantReader
Groups GroupMembers
Containers ContainerReader
Items ItemReader
Changes changelog.ChangeLog
HLC clock.HLCSource
}
Revocations tells the devices of a person that their read access has ended (offline-sync.md §6).
A phone that holds a hub its owner was removed from keeps the hub until something says otherwise, and neither the audit entry nor the event that `RevokeMembership` and the group changes write tells a device. This writes the one record that says it - an `ACCESS_REVOKED` entry at the container root, `actor_id` naming the account that lost access - and it is written where the *effective* access ends: after the removal, inside its transaction, the question "may this account still read here?" is put to the same resolution every request uses. A person who loses a group but keeps a direct grant loses nothing and is told nothing.
The record is addressed to a person rather than filtered by permission, which is why it is the one record the stream's filter does not ask the container about (sync.StreamChanges).
func (Revocations) AfterContainerMoved ¶
func (r Revocations) AfterContainerMoved(ctx context.Context, from, to work.Container) (Loss, error)
AfterContainerMoved describes who may have lost a collection by its move under another hub: everybody who held a role on the hub it left, directly or through a group. Whoever reads the collection some other way - a role on the collection itself, or on the workspace - is still asked and still keeps it; the list is who to ask, not who lost.
func (Revocations) AfterGrantRevoked ¶
AfterGrantRevoked describes the loss a revoked grant is: the account named, or every member of the group named, at the grant's scope. Resolved before the grant is gone, because a group's members are read from the group rather than from the grant.
func (Revocations) AfterGroupLoss ¶
func (r Revocations) AfterGroupLoss( ctx context.Context, groupID shared.ID, accounts []shared.ID, ) ([]Loss, error)
AfterGroupLoss describes what accounts taken out of a group - or every member of a group about to be deleted - stand to lose: one loss per grant the group holds. Read before the removal, for the same reason: a deleted group takes its grants with it.
func (Revocations) AfterHubMadePrivate ¶
AfterHubMadePrivate describes who may have lost a hub by its turning private: everybody who held a role on the workspace, directly or through a group - the roles a private hub no longer lets in (ADR-0073 §1). Announce asks each of them again, so whoever also holds a role in the hub keeps it and is told nothing.
func (Revocations) Announce ¶
Announce writes the record for every account of the loss that may no longer read what the scope names. It runs inside the transaction that made the removal, after it, so that the resolution sees the removal and nothing else that has not happened yet.
A loss at the workspace is announced per hub: the device applies a revocation to the subtree under the root it names, and the workspace is not a root a device holds. A loss at an entry is announced for the entry, under its collection.
type Service ¶
type Service struct {
Memberships repository.Memberships
UnitOfWork persistence.UnitOfWork
Audit audit.Sink
Clock clock.Clock
}
Service answers permission questions and records the refusals.
func (Service) Authorize ¶
func (s Service) Authorize(ctx context.Context, actor appshared.ActorContext, request Request) error
Authorize returns nil when the actor may proceed, and a typed refusal otherwise.
It must be called *before* the transaction that performs the operation, not inside it. A refusal writes an audit entry, and an entry written inside the caller's transaction would be rolled back together with the refusal - leaving exactly the record an auditor is looking for missing (test AT-3).
func (Service) AuthorizeOwnPrivateHub ¶
func (s Service) AuthorizeOwnPrivateHub( ctx context.Context, actor appshared.ActorContext, request Request, ) error
AuthorizeOwnPrivateHub answers whether the actor may create a private hub for themselves (ADR-0073 §2): a person - never a service account - who holds a role anywhere in the workspace, whatever it is, with the token scope the request names. No STRUCTURE: the person who most needs a hub nobody else opens is the one without an administrator's role.
Whether the workspace allows private hubs is not asked here: *Private hubs allowed* is a setting, not a permission, and the use case reads it after this answered (ADR-0073 §6).
A refusal is recorded like every other, against the request's action.
func (Service) CanSee ¶
func (s Service) CanSee( ctx context.Context, actor appshared.ActorContext, accountID shared.ID, path []identity.Scope, ) (bool, error)
CanSee answers the same membership question about somebody other than the actor: is there a role anywhere on this path that this account holds?
It exists because an assignment is a decision about a second person. Giving an entry to somebody who gets a 404 on it is a piece of work nobody can do, and a contributor's write right pointing at nothing, so the account has to hold a membership along the path (domain-model.md §3.2, service.EffectiveRole). Read rather than write, because that is what "can see it" means; every role in the matrix reads, so this is in practice "holds a role at all", and it is asked as the permission rather than as the presence of a row so that a role added later without a read right does not silently become assignable.
It is here rather than in the use case for the reason this whole package exists: an answer about who may reach what is one answer, and a second implementation of it in a work-management service is a second place for it to be wrong (ADR-0005).
The transaction is the actor's, so what it can see is the actor's tenant. An account of another tenant therefore holds no memberships this query can find and comes back false - which is the same answer as an account that does not exist, and deliberately: the caller must not be able to tell the two apart (T-04, multi-tenancy.md §2).
Nothing is audited. Nobody was refused anything - the actor's own permission was decided by Authorize before this runs, and this is a question about an argument they passed (audit.md §4).
func (Service) GrantableRoles ¶
func (s Service) GrantableRoles( ctx context.Context, actor appshared.ActorContext, request Request, ) ([]identity.Role, error)
GrantableRoles answers which roles the actor may hand out along the path, strongest first: the list a client offers before anybody presses anything, decided as Authorize decides a request that Grants one (P-05). Empty where the token may not write what the request names.
Nothing is audited. Nobody was refused anything: the actor asked what they may give.
func (Service) HeldOnWorkspace ¶
func (s Service) HeldOnWorkspace(ctx context.Context, actor appshared.ActorContext) ([]service.Permission, error)
HeldOnWorkspace answers every permission the actor holds on the workspace itself, and records nothing: what the manifest tells a client it may offer at that level before anybody presses anything (P-05). Asking is not an attempt (audit.md §4) - the alternative, a client probing a route to see whether it is refused, fills the trail with refusals nobody meant.
The account's roles only, as Permits: a token's scopes bound what it may do further, and are checked by Authorize where something is actually done.
func (Service) Hidden ¶
func (s Service) Hidden( ctx context.Context, actor appshared.ActorContext, path []identity.Scope, ) (bool, error)
Hidden answers whether the path runs through a private hub the actor holds nothing in - a hub that, for them, is not there (ADR-0073 §1). Nothing is recorded: it is asked about somebody on whose behalf something would run - a rule's run_as, a subscription's creator - before anything is read for them, and nobody was refused anything.
It answers only the private hub's question. A path the actor simply holds no role on is not hidden, so a reader that never asked before asks nothing new of a shared hub.
func (Service) MayOwnPrivateHub ¶
MayOwnPrivateHub answers AuthorizeOwnPrivateHub's question without recording anything, for the manifest: a person - never a service account - who holds a role anywhere in the workspace. The token's scope is not asked, as `HeldOnWorkspace` does not ask it: the manifest says what the person may do, and a token bounds it further.
func (Service) Permits ¶
func (s Service) Permits( ctx context.Context, actor appshared.ActorContext, request Request, ) (bool, error)
Permits answers whether the actor holds the permission along the path, and records nothing.
The counterpart of Authorize for a *filter* rather than an attempt, and the distinction is audit.md §4's rather than a convenience. The trail records what somebody tried to do and was refused; a change stream deciding that ninety of a hundred records are not for this client is nobody trying anything, and an entry per withheld record would bury the refusals that matter under a feed nobody even read.
It is not a second permission model: the resolution and the decision are the ones Authorize uses, and a caller that wants the entry writes Authorize instead. What it does not do is speak - so a token scope is checked once where a connection is accepted rather than per record in here (core/application/service/sync).
func (Service) Permitted ¶
func (s Service) Permitted( ctx context.Context, actor appshared.ActorContext, request Request, paths [][]identity.Scope, ) ([]bool, error)
Permitted answers the same question as Authorize for many paths at once: which of these may the actor do this to.
It exists for the one read the single check cannot serve. A list anchored to a container asks about one path and goes through Authorize; the hub level is anchored to nothing, and a check at the tenant scope would refuse everybody whose membership sits on a hub rather than on the workspace - which is most of the point of hub-scoped memberships (domain-model.md §3.2). So the rows are read first and the ones the actor may not see are dropped.
One membership read for the whole page, not one per row. The port's comment is explicit that the query may be generous and must not be unbounded, and the union of a page's scopes is bounded by the page size - which the use case has already clamped to the contract's maximum.
Nothing here is audited. A row left out of a list is not a denied access: nobody was refused anything, the actor asked what they can see and was told. The refusal that *is* recorded is the token scope, because that one refuses the whole operation rather than narrowing its answer (audit.md §4).
func (Service) ReachInto ¶
func (s Service) ReachInto( ctx context.Context, actor appshared.ActorContext, request Request, containerID shared.ID, ) (Reach, error)
ReachInto answers how far the actor reaches into one container's entries.
A list anchored to a container asks this rather than Authorize, because for a list there are two right answers rather than one. A role on the container answers for every row in it. An actor who holds none may still hold a membership on entries inside it - that is what "shared items only" is (domain-model.md §3.2) - and their level is those entries rather than a refusal.
The refusal is recorded once, and only when both come back empty. Asking Authorize first and then looking for shares would write a DENIED entry for somebody who was not in the end denied anything, which is a trail an auditor cannot read (audit.md §4).
The second query runs only for an actor who holds no role on the container, so the ordinary list pays one membership read exactly as it did before.
func (Service) RoleAlong ¶
func (s Service) RoleAlong( ctx context.Context, actor appshared.ActorContext, path []identity.Scope, ) (identity.Role, bool, error)
RoleAlong returns the actor's own effective role along the path, and whether they hold one at all.
It exists for the qualifiers the permission matrix deliberately does not fold in (service.Permission): "only the author or an administrator may change a comment" is a rule about *which* role the actor holds, not about a permission a role carries, and the use case that enforces it needs the role to say so. Here rather than in that use case for the reason CanSee is here: an answer about who may reach what is one answer (ADR-0005).
Nothing is audited. Nobody was refused anything - the caller asked a question, and whatever it refuses on the answer is its own act to record.
func (Service) RoleOf ¶
func (s Service) RoleOf( ctx context.Context, actor appshared.ActorContext, accountID shared.ID, path []identity.Scope, ) (identity.Role, bool, error)
RoleOf is RoleAlong about another account, read in the actor's tenant: the role an automation rule's `run_as` holds where the rule acts, which its writer may not exceed (automation.md §2.1). Here rather than in the automation service so that a private hub on the path is judged as everywhere else (ADR-0073 §1). An account of another tenant holds nothing this can find.
Nothing is audited, for RoleAlong's reason.
func (Service) WritesOnlyWhatIsAssigned ¶
func (s Service) WritesOnlyWhatIsAssigned( ctx context.Context, actor appshared.ActorContext, path []identity.Scope, ) (bool, error)
WritesOnlyWhatIsAssigned reports whether the role the actor holds along this path reaches only the entries assigned to them.
It is the one thing about the narrowing a use case has to know *before* it writes rather than after. A creation by somebody whose writes are narrowed that way has to land on them, or the entry they just made would be out of their own reach the moment it existed - so the create path asks this and assigns accordingly (domain-model.md §3.2).
What it hands back is "this entry has to be yours", not "you are a contributor". The role stays here and the matrix answers the question (service.ItemAccessOf), so a role added later with the same qualifier is covered without the create path being edited - which is the difference between consulting the decision point and copying a check out of it (ADR-0005).
Nothing is audited. Nobody was refused anything: the actor's own permission is decided by Authorize, and this asks what shape their write has to take (audit.md §4).