verify / verify (push) Successful in 3m2s
Sanitized allowlisted snapshot of private source 0acd276fb3423405daf7fff26dedc92b8281e2bd. Adds organization, team, invitation, resource hierarchy, scoped access, and audited break-glass foundations while preserving Preview 1. AI-Assistance: OpenAI Codex assisted implementation, testing, security review, and release preparation. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
271 lines
8.7 KiB
Go
271 lines
8.7 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 (
|
|
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)
|
|
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
|
|
}
|
|
|
|
type Service struct {
|
|
repository Repository
|
|
policy Policy
|
|
random io.Reader
|
|
now func() time.Time
|
|
}
|
|
|
|
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
|
|
}
|
|
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now}, 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
|
|
}
|
|
|
|
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 text(value string, limit int, emptyOK bool) bool {
|
|
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
|
|
}
|