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>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in New Issue
Block a user