This commit is contained in:
+87
-5
@@ -18,8 +18,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
|
||||
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
|
||||
@@ -116,6 +119,8 @@ type Repository interface {
|
||||
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
|
||||
@@ -123,8 +128,9 @@ type Repository interface {
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
OwnerRole string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -132,6 +138,7 @@ type Service struct {
|
||||
policy Policy
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
ownerRole string
|
||||
}
|
||||
|
||||
func New(repository Repository, policy Policy, options Options) (*Service, error) {
|
||||
@@ -147,7 +154,12 @@ func New(repository Repository, policy Policy, options Options) (*Service, error
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now}, nil
|
||||
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 {
|
||||
@@ -183,6 +195,62 @@ func (service *Service) Grant(ctx context.Context, input Grant) (Binding, error)
|
||||
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
|
||||
@@ -265,6 +333,20 @@ func randomID(random io.Reader) (string, error) {
|
||||
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")
|
||||
}
|
||||
|
||||
+62
-2
@@ -4,6 +4,8 @@ package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -56,9 +58,57 @@ func TestScopeHierarchyAndLifetimeFailClosed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizationUserRoleReplacementIsBoundedAndCanonical(t *testing.T) {
|
||||
now := time.Unix(2000, 0).UTC()
|
||||
policy := Policy{Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"}, Permissions: map[string]string{"site.view": "View site"}, Grants: map[string][]string{"owner": {"site.view"}, "viewer": {"site.view"}}}
|
||||
if _, err := New(&repositoryStub{}, policy, Options{OwnerRole: "missing"}); err == nil {
|
||||
t.Fatal("unknown owner role accepted")
|
||||
}
|
||||
repository := &repositoryStub{}
|
||||
service, err := New(repository, policy, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding, err := service.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{
|
||||
OrganizationID: "org-12345678",
|
||||
UserID: "user-12345678",
|
||||
Role: "viewer",
|
||||
ActorUserID: "user-87654321",
|
||||
RequestID: "request-12345678",
|
||||
ExpectedBindingIDs: []string{"binding-22222222", "binding-11111111"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binding.Role != "viewer" || binding.SubjectKind != User || binding.Scope != (Scope{OrganizationID: "org-12345678"}) || binding.GrantedAt != now {
|
||||
t.Fatalf("binding=%+v", binding)
|
||||
}
|
||||
if !slices.Equal(repository.replacedExpected, []string{"binding-11111111", "binding-22222222"}) || repository.replacedOwnerRole != "owner" {
|
||||
t.Fatalf("expected=%v owner=%q", repository.replacedExpected, repository.replacedOwnerRole)
|
||||
}
|
||||
if repository.replacedAccessAudit.Action != "access.role.replace" || repository.replacedAccessAudit.ResourceID != "user-12345678" || repository.replacedAccessAudit.RequestID != "request-12345678" {
|
||||
t.Fatalf("audit=%+v", repository.replacedAccessAudit)
|
||||
}
|
||||
if _, err = service.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{OrganizationID: "org-12345678", UserID: "user-12345678", Role: "viewer", ActorUserID: "user-87654321", ExpectedBindingIDs: []string{"binding-11111111", "binding-11111111"}}); err == nil {
|
||||
t.Fatal("duplicate expected binding accepted")
|
||||
}
|
||||
serviceWithoutOwner, err := New(&repositoryStub{}, policy, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = serviceWithoutOwner.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{}); err == nil || errors.Is(err, ErrRoleChangeConflict) {
|
||||
t.Fatalf("missing owner role err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type repositoryStub struct {
|
||||
bindings []Binding
|
||||
breakGlass []BreakGlass
|
||||
bindings []Binding
|
||||
breakGlass []BreakGlass
|
||||
organizationUser []Binding
|
||||
replacedExpected []string
|
||||
replacedBinding Binding
|
||||
replacedOwnerRole string
|
||||
replacedAccessAudit AuditEvent
|
||||
}
|
||||
|
||||
func (*repositoryStub) SeedAccessPolicy(context.Context, Policy) error { return nil }
|
||||
@@ -67,6 +117,16 @@ func (*repositoryStub) Revoke(context.Context, string, string, time.Time) error
|
||||
func (repository *repositoryStub) EffectiveBindings(context.Context, string, string) ([]Binding, error) {
|
||||
return repository.bindings, nil
|
||||
}
|
||||
func (repository *repositoryStub) OrganizationUserBindings(context.Context, string, int) ([]Binding, error) {
|
||||
return repository.organizationUser, nil
|
||||
}
|
||||
func (repository *repositoryStub) ReplaceOrganizationUserRole(_ context.Context, expected []string, binding Binding, ownerRole string, audit AuditEvent) error {
|
||||
repository.replacedExpected = append([]string(nil), expected...)
|
||||
repository.replacedBinding = binding
|
||||
repository.replacedOwnerRole = ownerRole
|
||||
repository.replacedAccessAudit = audit
|
||||
return nil
|
||||
}
|
||||
func (repository *repositoryStub) CreateBreakGlass(_ context.Context, grant BreakGlass, _ AuditEvent) error {
|
||||
repository.breakGlass = []BreakGlass{grant}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user