Files
web/access/access.go
T
gamertan fe6bd94c9a
verify / verify (push) Successful in 3m39s
Add atomic organization role administration
2026-09-03 22:51:53 -04:00

353 lines
12 KiB
Go

// SPDX-License-Identifier: MPL-2.0
// Package access defines organization-scoped role bindings and audited,
// short-lived break-glass authorization.
package access
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"regexp"
"sort"
"strings"
"time"
)
var (
ErrLastOwner = errors.New("access: the last active direct owner must be preserved")
ErrRoleChangeConflict = errors.New("access: role binding changed")
ErrRoleUnchanged = errors.New("access: role is unchanged")
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
)
type SubjectKind string
const (
User SubjectKind = "user"
Team SubjectKind = "team"
)
type Scope struct {
OrganizationID string
ProjectID string
EnvironmentID string
ServiceID string
}
func (scope Scope) Validate() error {
if !idPattern.MatchString(scope.OrganizationID) || scope.ProjectID != "" && !idPattern.MatchString(scope.ProjectID) || scope.EnvironmentID != "" && !idPattern.MatchString(scope.EnvironmentID) || scope.ServiceID != "" && !idPattern.MatchString(scope.ServiceID) {
return errors.New("access: invalid scope")
}
if scope.EnvironmentID != "" && scope.ProjectID == "" || scope.ServiceID != "" && scope.EnvironmentID == "" {
return errors.New("access: incomplete scope hierarchy")
}
return nil
}
func (scope Scope) contains(requested Scope) bool {
if scope.OrganizationID != requested.OrganizationID {
return false
}
for _, pair := range [][2]string{{scope.ProjectID, requested.ProjectID}, {scope.EnvironmentID, requested.EnvironmentID}, {scope.ServiceID, requested.ServiceID}} {
if pair[0] != "" && pair[0] != pair[1] {
return false
}
}
return true
}
type Binding struct {
ID string
SubjectKind SubjectKind
SubjectID string
Role string
Scope Scope
GrantedBy string
GrantedAt time.Time
}
type Policy struct {
Roles map[string]string
Permissions map[string]string
Grants map[string][]string
}
func (policy Policy) Validate() error {
if len(policy.Roles) == 0 || len(policy.Roles) > 1000 || len(policy.Permissions) == 0 || len(policy.Permissions) > 10000 || len(policy.Grants) > 1000 {
return errors.New("access: invalid policy size")
}
for name, description := range policy.Roles {
if !namePattern.MatchString(name) || !text(description, 512, true) {
return errors.New("access: invalid role")
}
}
for name, description := range policy.Permissions {
if !namePattern.MatchString(name) || !text(description, 512, true) {
return errors.New("access: invalid permission")
}
}
for role, permissions := range policy.Grants {
if _, ok := policy.Roles[role]; !ok || len(permissions) > 10000 {
return errors.New("access: invalid role grant")
}
for _, permission := range permissions {
if _, ok := policy.Permissions[permission]; !ok {
return errors.New("access: role references unknown permission")
}
}
}
return nil
}
type BreakGlass struct {
ID, OrganizationID, UserID, Permission, Reason string
CreatedAt, ExpiresAt time.Time
}
type AuditEvent struct {
ID, OrganizationID, ActorUserID, Action, ResourceType, ResourceID, RequestID, Summary string
CreatedAt time.Time
}
type Repository interface {
SeedAccessPolicy(context.Context, Policy) error
Grant(context.Context, Binding) error
Revoke(context.Context, string, string, time.Time) error
EffectiveBindings(context.Context, string, string) ([]Binding, error)
OrganizationUserBindings(context.Context, string, int) ([]Binding, error)
ReplaceOrganizationUserRole(context.Context, []string, Binding, string, AuditEvent) error
CreateBreakGlass(context.Context, BreakGlass, AuditEvent) error
ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error)
AppendAccessAudit(context.Context, AuditEvent) error
AccessAudit(context.Context, string, int) ([]AuditEvent, error)
}
type Options struct {
Random io.Reader
Now func() time.Time
OwnerRole string
}
type Service struct {
repository Repository
policy Policy
random io.Reader
now func() time.Time
ownerRole string
}
func New(repository Repository, policy Policy, options Options) (*Service, error) {
if repository == nil {
return nil, errors.New("access: repository is required")
}
if err := policy.Validate(); err != nil {
return nil, err
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
if options.OwnerRole != "" {
if _, ok := policy.Roles[options.OwnerRole]; !ok {
return nil, errors.New("access: owner role is unknown")
}
}
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now, ownerRole: options.OwnerRole}, nil
}
func (service *Service) Seed(ctx context.Context) error {
return service.repository.SeedAccessPolicy(ctx, service.policy)
}
type Grant struct {
SubjectKind SubjectKind
SubjectID string
Role string
Scope Scope
GrantedBy string
}
func (service *Service) Grant(ctx context.Context, input Grant) (Binding, error) {
if (input.SubjectKind != User && input.SubjectKind != Team) || !idPattern.MatchString(input.SubjectID) || !idPattern.MatchString(input.GrantedBy) {
return Binding{}, errors.New("access: invalid binding subject")
}
if _, ok := service.policy.Roles[input.Role]; !ok {
return Binding{}, errors.New("access: unknown role")
}
if err := input.Scope.Validate(); err != nil {
return Binding{}, err
}
id, err := randomID(service.random)
if err != nil {
return Binding{}, err
}
binding := Binding{ID: id, SubjectKind: input.SubjectKind, SubjectID: input.SubjectID, Role: input.Role, Scope: input.Scope, GrantedBy: input.GrantedBy, GrantedAt: service.now().UTC()}
if err = service.repository.Grant(ctx, binding); err != nil {
return Binding{}, err
}
return binding, nil
}
// OrganizationUserBindings lists active, direct, organization-wide user role
// bindings. Team and narrower project/environment/service grants remain
// separate because an administration screen must not silently flatten their
// authority into one apparent role.
func (service *Service) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]Binding, error) {
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("access: invalid organization binding query")
}
return service.repository.OrganizationUserBindings(ctx, organizationID, limit)
}
type OrganizationUserRoleChange struct {
OrganizationID string
UserID string
Role string
ActorUserID string
RequestID string
ExpectedBindingIDs []string
}
// ReplaceOrganizationUserRole atomically replaces every current direct,
// organization-wide role for one active member with exactly one role. The
// expected binding IDs make concurrent administration fail closed. When an
// owner role is configured, the repository also protects the final active
// direct owner in the same transaction.
func (service *Service) ReplaceOrganizationUserRole(ctx context.Context, input OrganizationUserRoleChange) (Binding, error) {
if service.ownerRole == "" {
return Binding{}, errors.New("access: owner role is required for role replacement")
}
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || !text(input.RequestID, 128, true) {
return Binding{}, errors.New("access: invalid organization role replacement")
}
if _, ok := service.policy.Roles[input.Role]; !ok {
return Binding{}, errors.New("access: unknown role")
}
expected, err := canonicalBindingIDs(input.ExpectedBindingIDs)
if err != nil {
return Binding{}, err
}
bindingID, err := randomID(service.random)
if err != nil {
return Binding{}, err
}
auditID, err := randomID(service.random)
if err != nil {
return Binding{}, err
}
now := service.now().UTC()
binding := Binding{ID: bindingID, SubjectKind: User, SubjectID: input.UserID, Role: input.Role, Scope: Scope{OrganizationID: input.OrganizationID}, GrantedBy: input.ActorUserID, GrantedAt: now}
audit := AuditEvent{ID: auditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.role.replace", ResourceType: "user", ResourceID: input.UserID, RequestID: input.RequestID, Summary: "Direct organization role replaced", CreatedAt: now}
if err = service.repository.ReplaceOrganizationUserRole(ctx, expected, binding, service.ownerRole, audit); err != nil {
return Binding{}, err
}
return binding, nil
}
type Decision struct {
Allowed bool
Source string
Role string
}
func (service *Service) Authorize(ctx context.Context, userID string, scope Scope, permission string) (Decision, error) {
if !idPattern.MatchString(userID) || !namePattern.MatchString(permission) {
return Decision{}, errors.New("access: invalid authorization request")
}
if err := scope.Validate(); err != nil {
return Decision{}, err
}
if _, ok := service.policy.Permissions[permission]; !ok {
return Decision{}, errors.New("access: unknown permission")
}
bindings, err := service.repository.EffectiveBindings(ctx, scope.OrganizationID, userID)
if err != nil {
return Decision{}, err
}
sort.Slice(bindings, func(i, j int) bool { return bindings[i].ID < bindings[j].ID })
for _, binding := range bindings {
if !binding.Scope.contains(scope) {
continue
}
for _, granted := range service.policy.Grants[binding.Role] {
if granted == permission {
return Decision{Allowed: true, Source: "role", Role: binding.Role}, nil
}
}
}
breakGlass, err := service.repository.ActiveBreakGlass(ctx, scope.OrganizationID, userID, service.now().UTC())
if err != nil {
return Decision{}, err
}
for _, grant := range breakGlass {
if grant.Permission == permission {
return Decision{Allowed: true, Source: "break_glass"}, nil
}
}
return Decision{}, nil
}
func (service *Service) ActivateBreakGlass(ctx context.Context, organizationID, userID, permission, reason, requestID string, lifetime time.Duration) (BreakGlass, error) {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) || !namePattern.MatchString(permission) || !text(strings.TrimSpace(reason), 1024, false) || !text(requestID, 128, true) || lifetime < time.Minute || lifetime > time.Hour {
return BreakGlass{}, errors.New("access: invalid break-glass request")
}
if _, ok := service.policy.Permissions[permission]; !ok {
return BreakGlass{}, errors.New("access: unknown permission")
}
id, err := randomID(service.random)
if err != nil {
return BreakGlass{}, err
}
auditID, err := randomID(service.random)
if err != nil {
return BreakGlass{}, err
}
now := service.now().UTC()
grant := BreakGlass{ID: id, OrganizationID: organizationID, UserID: userID, Permission: permission, Reason: strings.TrimSpace(reason), CreatedAt: now, ExpiresAt: now.Add(lifetime)}
audit := AuditEvent{ID: auditID, OrganizationID: organizationID, ActorUserID: userID, Action: "break_glass.activate", ResourceType: "organization", ResourceID: organizationID, RequestID: requestID, Summary: "Temporary emergency access activated", CreatedAt: now}
if err = service.repository.CreateBreakGlass(ctx, grant, audit); err != nil {
return BreakGlass{}, err
}
return grant, nil
}
func (service *Service) Audit(ctx context.Context, organizationID string, limit int) ([]AuditEvent, error) {
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 1000 {
return nil, errors.New("access: invalid audit query")
}
return service.repository.AccessAudit(ctx, organizationID, limit)
}
func randomID(random io.Reader) (string, error) {
value := make([]byte, 18)
if _, err := io.ReadFull(random, value); err != nil {
return "", fmt.Errorf("access: secure randomness unavailable: %w", err)
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func canonicalBindingIDs(values []string) ([]string, error) {
if len(values) > 16 {
return nil, errors.New("access: invalid expected role bindings")
}
result := append([]string(nil), values...)
sort.Strings(result)
for index, value := range result {
if !idPattern.MatchString(value) || index > 0 && result[index-1] == value {
return nil, errors.New("access: invalid expected role bindings")
}
}
return result, nil
}
func text(value string, limit int, emptyOK bool) bool {
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
}