Support atomic organization role sets and owner-managed invitations
verify / verify (push) Successful in 4m17s
verify / verify (push) Successful in 4m17s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
)
|
||||
|
||||
var ErrRoleSetUnsupported = errors.New("access: atomic role sets are unsupported")
|
||||
|
||||
// RoleSetRepository commits every replacement and the audit atomically. There
|
||||
// is no sequence of individual Grant/Revoke calls as a fallback.
|
||||
type RoleSetRepository interface {
|
||||
ReplaceOrganizationUserRoles(context.Context, []string, []Binding, string, AuditEvent) error
|
||||
}
|
||||
|
||||
type OrganizationUserRolesChange struct {
|
||||
OrganizationID, UserID, ActorUserID, RequestID string
|
||||
Roles, ExpectedBindingIDs []string
|
||||
}
|
||||
|
||||
// ReplaceOrganizationUserRoles replaces the direct organization-wide role set
|
||||
// for one active member. Team and narrower grants are unaffected. This bulk
|
||||
// operation requires a current direct owner inside the write transaction;
|
||||
// applications still authorize their customer/merchant and allowed-role boundary.
|
||||
func (service *Service) ReplaceOrganizationUserRoles(ctx context.Context, input OrganizationUserRolesChange) ([]Binding, error) {
|
||||
repository, ok := service.repository.(RoleSetRepository)
|
||||
if !ok {
|
||||
return nil, ErrRoleSetUnsupported
|
||||
}
|
||||
if service.ownerRole == "" || !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || !text(input.RequestID, 128, true) || len(input.Roles) < 1 || len(input.Roles) > 16 {
|
||||
return nil, errors.New("access: invalid organization role set")
|
||||
}
|
||||
roles := append([]string(nil), input.Roles...)
|
||||
sort.Strings(roles)
|
||||
for i, role := range roles {
|
||||
if _, exists := service.policy.Roles[role]; !exists || i > 0 && roles[i-1] == role {
|
||||
return nil, errors.New("access: unknown or duplicate role")
|
||||
}
|
||||
}
|
||||
expected, err := canonicalBindingIDs(input.ExpectedBindingIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
bindings := make([]Binding, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
id, err := randomID(service.random)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bindings = append(bindings, Binding{ID: id, SubjectKind: User, SubjectID: input.UserID, Role: role, Scope: Scope{OrganizationID: input.OrganizationID}, GrantedBy: input.ActorUserID, GrantedAt: now})
|
||||
}
|
||||
id, err := randomID(service.random)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
audit := AuditEvent{ID: id, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.role.replace", ResourceType: "user", ResourceID: input.UserID, RequestID: input.RequestID, Summary: "Direct organization roles replaced", CreatedAt: now}
|
||||
if err := repository.ReplaceOrganizationUserRoles(ctx, expected, bindings, service.ownerRole, audit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bindings, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roleSetRepositoryStub struct {
|
||||
repositoryStub
|
||||
calls int
|
||||
expected []string
|
||||
roles []Binding
|
||||
audit AuditEvent
|
||||
}
|
||||
|
||||
func (r *roleSetRepositoryStub) ReplaceOrganizationUserRoles(_ context.Context, expected []string, bindings []Binding, _ string, audit AuditEvent) error {
|
||||
r.calls++
|
||||
r.expected, r.roles, r.audit = expected, bindings, audit
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRoleSetServiceBoundsAndCanonicalCopies(t *testing.T) {
|
||||
policy := Policy{Roles: map[string]string{"owner": "Owner", "buyer": "Buyer", "billing": "Billing"}, Permissions: map[string]string{"purchase": "Purchase"}, Grants: map[string][]string{"owner": {"purchase"}, "buyer": {"purchase"}, "billing": {}}}
|
||||
r := &roleSetRepositoryStub{}
|
||||
service, err := New(r, policy, Options{OwnerRole: "owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := OrganizationUserRolesChange{OrganizationID: "organization-123", UserID: "member-12345678", ActorUserID: "owner-12345678", Roles: []string{"buyer", "billing"}, ExpectedBindingIDs: []string{"binding-second", "binding-first"}, RequestID: "request-roles"}
|
||||
bindings, err := service.ReplaceOrganizationUserRoles(t.Context(), input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.calls != 1 || len(bindings) != 2 || bindings[0].Role != "billing" || bindings[1].Role != "buyer" || bindings[0].ID == bindings[1].ID || !slices.Equal(r.expected, []string{"binding-first", "binding-second"}) {
|
||||
t.Fatalf("bindings=%v expected=%v calls=%d", bindings, r.expected, r.calls)
|
||||
}
|
||||
if !slices.Equal(input.Roles, []string{"buyer", "billing"}) || !slices.Equal(input.ExpectedBindingIDs, []string{"binding-second", "binding-first"}) {
|
||||
t.Fatal("caller input was sorted in place")
|
||||
}
|
||||
if r.audit.RequestID != input.RequestID || r.audit.ActorUserID != input.ActorUserID || r.audit.ResourceID != input.UserID {
|
||||
t.Fatalf("audit=%+v", r.audit)
|
||||
}
|
||||
for _, roles := range [][]string{nil, {"buyer", "buyer"}, {"missing"}, make([]string, 17)} {
|
||||
invalid := input
|
||||
invalid.Roles = roles
|
||||
if _, err = service.ReplaceOrganizationUserRoles(t.Context(), invalid); err == nil {
|
||||
t.Fatalf("invalid roles=%v", roles)
|
||||
}
|
||||
}
|
||||
for _, expected := range [][]string{{"bad"}, {"binding-first", "binding-first"}, make([]string, 17)} {
|
||||
invalid := input
|
||||
invalid.ExpectedBindingIDs = expected
|
||||
if _, err = service.ReplaceOrganizationUserRoles(t.Context(), invalid); err == nil {
|
||||
t.Fatalf("invalid IDs=%v", expected)
|
||||
}
|
||||
}
|
||||
if r.calls != 1 {
|
||||
t.Fatal("invalid input reached repository")
|
||||
}
|
||||
legacy, err := New(&repositoryStub{}, policy, Options{OwnerRole: "owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = legacy.ReplaceOrganizationUserRoles(t.Context(), input); !errors.Is(err, ErrRoleSetUnsupported) {
|
||||
t.Fatalf("fallback=%v", err)
|
||||
}
|
||||
broken, err := New(r, policy, Options{OwnerRole: "owner", Random: strings.NewReader("")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = broken.ReplaceOrganizationUserRoles(t.Context(), input); err == nil || r.calls != 1 {
|
||||
t.Fatal("random failure reached storage")
|
||||
}
|
||||
withoutOwner, err := New(r, policy, Options{Now: func() time.Time { return time.Unix(2000, 0) }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = withoutOwner.ReplaceOrganizationUserRoles(t.Context(), input); err == nil || r.calls != 1 {
|
||||
t.Fatal("role set without owner boundary accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user