diff --git a/CHANGELOG.md b/CHANGELOG.md index f529c68..fab8cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ # Changelog +## v0.1.0-preview.16 — 2026-09-03 + +- Add bounded organization-member and direct user-role listings for + application-owned access administration pages. Direct listings deliberately + exclude team and narrower resource grants rather than flattening distinct + authority into one apparent role. +- Add atomic direct-role replacement with exact expected-binding checks, + transactional access audit, active-member validation, and final active + direct-owner protection. SQLite serializes competing replacements so stale + administration fails with a stable conflict instead of partially applying. +- Record the Gamertan administration dogfood boundary: applications authorize + the route and fresh passkey assertion, while Foundations owns the reusable + storage transaction and invariants. + ## v0.1.0-preview.15 — 2026-09-03 - Permit applications to opt into an exact non-default HTTPS WebAuthn origin diff --git a/README.md b/README.md index 41c88c6..fe2c77d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and deployment. Adopt one boundary at a time; Go compiles and links only the packages you import. -> **Public preview:** `v0.1.0-preview.15`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.16`. APIs may change before a stable > release. Linux is the maintained release platform. ## Why Web Foundations? @@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy. Pin the preview in an application module: ```bash -go get gamertan.com/web@v0.1.0-preview.15 +go get gamertan.com/web@v0.1.0-preview.16 go mod verify ``` An application may name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.15 +go get gamertan.com/web/requestmeta@v0.1.0-preview.16 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/access/access.go b/access/access.go index 78a6988..950c2e2 100644 --- a/access/access.go +++ b/access/access.go @@ -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") } diff --git a/access/access_test.go b/access/access_test.go index 93b61b8..1dff362 100644 --- a/access/access_test.go +++ b/access/access_test.go @@ -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 diff --git a/authsqlite/access.go b/authsqlite/access.go index 0bb7245..67f2dc0 100644 --- a/authsqlite/access.go +++ b/authsqlite/access.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "errors" + "slices" "time" "gamertan.com/web/access" @@ -134,6 +135,159 @@ func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userI return result, rows.Err() } +func (store *Store) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]access.Binding, error) { + if !opaqueID(organizationID) || limit < 1 || limit > 2000 { + return nil, errors.New("authsqlite: invalid organization binding query") + } + rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_id,b.role_name,b.granted_by_user_id,b.granted_at + FROM gwf_access_bindings b + JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id + WHERE b.organization_id=? AND b.subject_kind='user' + AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL + AND b.revoked_at IS NULL + ORDER BY b.subject_id,b.role_name,b.id + LIMIT ?`, organizationID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]access.Binding, 0) + for rows.Next() { + var binding access.Binding + var granted int64 + if err = rows.Scan(&binding.ID, &binding.SubjectID, &binding.Role, &binding.GrantedBy, &granted); err != nil { + return nil, err + } + binding.SubjectKind = access.User + binding.Scope = access.Scope{OrganizationID: organizationID} + binding.GrantedAt = time.Unix(granted, 0).UTC() + result = append(result, binding) + } + return result, rows.Err() +} + +func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) error { + if !validOrganizationRoleReplacement(expected, replacement, ownerRole, audit) { + return errors.New("authsqlite: invalid organization role replacement") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Acquire the SQLite write lock before reading the optimistic binding set. + // This serializes competing role replacements so the loser observes the + // committed binding IDs and returns ErrRoleChangeConflict instead of an + // ambiguous busy-snapshot error. + result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status + WHERE organization_id=? AND user_id=? AND status='active' + AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active') + AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active')`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return errors.New("authsqlite: role grantor is not active in organization") + } + + var active int + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) + FROM gwf_organization_memberships m + JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active' + JOIN gwf_users u ON u.id=m.user_id AND u.status='active' + WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil { + return err + } + if active != 1 { + return errors.New("authsqlite: access subject is not active in organization") + } + + rows, err := tx.QueryContext(ctx, `SELECT id,role_name FROM gwf_access_bindings + WHERE organization_id=? AND subject_kind='user' AND subject_id=? + AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL + AND revoked_at IS NULL ORDER BY id`, replacement.Scope.OrganizationID, replacement.SubjectID) + if err != nil { + return err + } + var currentIDs []string + var currentRoles []string + for rows.Next() { + var id, role string + if err = rows.Scan(&id, &role); err != nil { + rows.Close() + return err + } + currentIDs = append(currentIDs, id) + currentRoles = append(currentRoles, role) + } + if err = rows.Err(); err != nil { + rows.Close() + return err + } + if err = rows.Close(); err != nil { + return err + } + if !slices.Equal(currentIDs, expected) { + return access.ErrRoleChangeConflict + } + if len(currentRoles) == 1 && currentRoles[0] == replacement.Role { + return access.ErrRoleUnchanged + } + if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) { + var otherOwners int + if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id) + FROM gwf_access_bindings b + JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active' + JOIN gwf_users u ON u.id=m.user_id AND u.status='active' + WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=? + AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL + AND b.revoked_at IS NULL`, replacement.Scope.OrganizationID, replacement.SubjectID, ownerRole).Scan(&otherOwners); err != nil { + return err + } + if otherOwners == 0 { + return access.ErrLastOwner + } + } + + if _, err = tx.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=? + WHERE organization_id=? AND subject_kind='user' AND subject_id=? + AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL + AND revoked_at IS NULL`, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Scope.OrganizationID, replacement.SubjectID); err != nil { + return err + } + result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) + SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, replacement.ID, replacement.Scope.OrganizationID, replacement.SubjectID, replacement.Role, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Role) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return errors.New("authsqlite: replacement role has not been seeded") + } + if err = appendAccessAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + +func validOrganizationRoleReplacement(expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) bool { + if !safeName(ownerRole) || !opaqueID(replacement.ID) || replacement.SubjectKind != access.User || !opaqueID(replacement.SubjectID) || !safeName(replacement.Role) || replacement.Scope.Validate() != nil || replacement.Scope.ProjectID != "" || replacement.Scope.EnvironmentID != "" || replacement.Scope.ServiceID != "" || !opaqueID(replacement.GrantedBy) || replacement.GrantedAt.IsZero() { + return false + } + if !validAccessAudit(audit) || audit.OrganizationID != replacement.Scope.OrganizationID || audit.ActorUserID != replacement.GrantedBy || audit.Action != "access.role.replace" || audit.ResourceType != "user" || audit.ResourceID != replacement.SubjectID || !audit.CreatedAt.Equal(replacement.GrantedAt) { + return false + } + if len(expected) > 16 || !slices.IsSorted(expected) { + return false + } + for index, id := range expected { + if !opaqueID(id) || index > 0 && expected[index-1] == id { + return false + } + } + return true +} + func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlass, audit access.AuditEvent) error { if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID { return errors.New("authsqlite: invalid break-glass event") diff --git a/authsqlite/organizations.go b/authsqlite/organizations.go index e821b37..84e5d86 100644 --- a/authsqlite/organizations.go +++ b/authsqlite/organizations.go @@ -261,6 +261,34 @@ func (store *Store) MembershipsForUser(ctx context.Context, userID string) ([]or return result, rows.Err() } +func (store *Store) OrganizationMemberships(ctx context.Context, organizationID string, limit int) ([]organizations.Membership, error) { + if !opaqueID(organizationID) || limit < 1 || limit > 2000 { + return nil, errors.New("authsqlite: invalid organization member query") + } + rows, err := store.db.QueryContext(ctx, `SELECT m.user_id,m.status,m.joined_at + FROM gwf_organization_memberships m + JOIN gwf_organizations o ON o.id=m.organization_id + WHERE m.organization_id=? + ORDER BY m.joined_at,m.user_id + LIMIT ?`, organizationID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]organizations.Membership, 0) + for rows.Next() { + var membership organizations.Membership + var joined int64 + if err = rows.Scan(&membership.UserID, &membership.Status, &joined); err != nil { + return nil, err + } + membership.OrganizationID = organizationID + membership.JoinedAt = time.Unix(joined, 0).UTC() + result = append(result, membership) + } + return result, rows.Err() +} + func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID string) ([]organizations.Team, error) { if !opaqueID(organizationID) || !opaqueID(userID) { return nil, errors.New("authsqlite: invalid team query") diff --git a/authsqlite/store_test.go b/authsqlite/store_test.go index bfe8e45..828ebd7 100644 --- a/authsqlite/store_test.go +++ b/authsqlite/store_test.go @@ -477,3 +477,169 @@ func TestInvitationAccessLifecycleAndLastOwnerProtection(t *testing.T) { t.Fatalf("archived organization decision=%+v err=%v", decision, err) } } + +func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) { + store, err := Open(filepath.Join(t.TempDir(), "accounts.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Date(2026, 9, 3, 16, 0, 0, 0, time.UTC) + authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "access.owner", Email: "access-owner@example.test", DisplayName: "Access Owner", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "access.member", Email: "access-member@example.test", DisplayName: "Access Member", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"}) + if err != nil { + t.Fatal(err) + } + organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "access-admin", Name: "Access Admin", OwnerUserID: owner.ID}) + if err != nil { + t.Fatal(err) + } + raw, _, err := organizationService.Invite(t.Context(), organization.ID, member.Email, owner.ID, time.Hour) + if err != nil { + t.Fatal(err) + } + if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil { + t.Fatal(err) + } + policy := access.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"}}, + } + accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }, OwnerRole: "owner"}) + if err != nil { + t.Fatal(err) + } + if err = accessService.Seed(t.Context()); err != nil { + t.Fatal(err) + } + ownerBinding, err := accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}) + if err != nil { + t.Fatal(err) + } + memberBinding, err := accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}) + if err != nil { + t.Fatal(err) + } + project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "narrow", Name: "Narrow"}) + if err != nil { + t.Fatal(err) + } + if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID, ProjectID: project.ID}, GrantedBy: owner.ID}); err != nil { + t.Fatal(err) + } + + members, err := organizationService.Members(t.Context(), organization.ID, 10) + if err != nil || len(members) != 2 || !membershipPresent(members, owner.ID, "active") || !membershipPresent(members, member.ID, "active") { + t.Fatalf("members=%+v err=%v", members, err) + } + direct, err := accessService.OrganizationUserBindings(t.Context(), organization.ID, 10) + if err != nil || len(direct) != 2 { + t.Fatalf("direct=%+v err=%v", direct, err) + } + + type replacementResult struct { + binding access.Binding + err error + } + start := make(chan struct{}) + results := make(chan replacementResult, 2) + for _, requestID := range []string{"request-member-owner-one", "request-member-owner-two"} { + requestID := requestID + go func() { + <-start + binding, replaceErr := accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: owner.ID, RequestID: requestID, ExpectedBindingIDs: []string{memberBinding.ID}}) + results <- replacementResult{binding: binding, err: replaceErr} + }() + } + close(start) + var memberOwner access.Binding + var successful, conflicted int + for range 2 { + result := <-results + switch { + case result.err == nil: + successful++ + memberOwner = result.binding + case errors.Is(result.err, access.ErrRoleChangeConflict): + conflicted++ + default: + t.Fatalf("concurrent replacement err=%v", result.err) + } + } + if successful != 1 || conflicted != 1 { + t.Fatalf("concurrent replacements success=%d conflict=%d", successful, conflicted) + } + if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: owner.ID, RequestID: "request-stale", ExpectedBindingIDs: []string{memberBinding.ID}}); !errors.Is(err, access.ErrRoleChangeConflict) { + t.Fatalf("stale replacement err=%v", err) + } + if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: owner.ID, RequestID: "request-unchanged", ExpectedBindingIDs: []string{memberOwner.ID}}); !errors.Is(err, access.ErrRoleUnchanged) { + t.Fatalf("unchanged replacement err=%v", err) + } + ownerViewer, err := accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-owner-viewer", ExpectedBindingIDs: []string{ownerBinding.ID}}) + if err != nil { + t.Fatal(err) + } + if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-last-owner", ExpectedBindingIDs: []string{memberOwner.ID}}); !errors.Is(err, access.ErrLastOwner) { + t.Fatalf("last-owner demotion err=%v", err) + } + if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "owner", ActorUserID: member.ID, RequestID: "request-restore-owner", ExpectedBindingIDs: []string{ownerViewer.ID}}); err != nil { + t.Fatal(err) + } + if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, member.ID, "suspended", owner.ID, "request-suspend"); err != nil { + t.Fatal(err) + } + members, err = organizationService.Members(t.Context(), organization.ID, 10) + if err != nil || len(members) != 2 || !membershipPresent(members, member.ID, "suspended") { + t.Fatalf("suspended members=%+v err=%v", members, err) + } + if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: owner.ID, RequestID: "request-suspended", ExpectedBindingIDs: []string{memberOwner.ID}}); err == nil { + t.Fatal("suspended member role was replaced") + } + if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, member.ID, "active", owner.ID, "request-reactivate"); err != nil { + t.Fatal(err) + } + + duplicateAudit := access.AuditEvent{ID: "audit-duplicate-1234", OrganizationID: organization.ID, ActorUserID: owner.ID, Action: "access.role.replace", ResourceType: "user", ResourceID: member.ID, RequestID: "request-rollback", Summary: "Direct organization role replaced", CreatedAt: now} + if err = store.AppendAccessAudit(t.Context(), duplicateAudit); err != nil { + t.Fatal(err) + } + replacement := access.Binding{ID: "binding-rollback-1234", SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID, GrantedAt: now} + if err = store.ReplaceOrganizationUserRole(t.Context(), []string{memberOwner.ID}, replacement, "owner", duplicateAudit); err == nil { + t.Fatal("audit failure did not roll back role replacement") + } + direct, err = store.OrganizationUserBindings(t.Context(), organization.ID, 10) + if err != nil { + t.Fatal(err) + } + var memberRoles []string + for _, binding := range direct { + if binding.SubjectID == member.ID { + memberRoles = append(memberRoles, binding.ID+":"+binding.Role) + } + } + if len(memberRoles) != 1 || memberRoles[0] != memberOwner.ID+":owner" { + t.Fatalf("rollback member roles=%v", memberRoles) + } + assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0) +} + +func membershipPresent(values []organizations.Membership, userID, status string) bool { + for _, value := range values { + if value.UserID == userID && value.Status == status { + return true + } + } + return false +} diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index 7169a2c..1ed2a22 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -54,3 +54,9 @@ application concern belongs in the shared module. service now permits an explicit development port only when applications opt in and the RP ID is `localhost` or reserved `.test`; production origins keep the original portless default. +- Gamertan's staff-access page exposed a dangerous composition gap between + individual grant/revoke calls. Foundations now owns one optimistic, + transactional direct-role replacement that preserves the final active + owner and appends its audit before commit. The application still owns route + authorization, role presentation, CSRF, and the exact fresh-passkey + operation binding. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 9d72c1a..fb6a6fd 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.15 +go get gamertan.com/web/requestmeta@v0.1.0-preview.16 go mod verify ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index a001e06..13c5413 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta" and request the containing module at an exact version: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.15 +go get gamertan.com/web/requestmeta@v0.1.0-preview.16 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/ORGANIZATIONS.md b/docs/ORGANIZATIONS.md index c17c659..c4f7008 100644 --- a/docs/ORGANIZATIONS.md +++ b/docs/ORGANIZATIONS.md @@ -22,6 +22,18 @@ team membership can be removed independently. Configure `OwnerRole` when constructing the service before exposing membership-removal operations. The SQLite adapter then refuses to suspend or remove the final active direct owner. +For a reviewed access-administration page, use `organizations.Members` to list +bounded active and suspended memberships, and +`access.OrganizationUserBindings` to list only current direct, +organization-wide user roles. The latter intentionally excludes team grants +and project, environment, or service bindings. Replace a member's direct role +with `access.ReplaceOrganizationUserRole`, passing the exact displayed binding +IDs as `ExpectedBindingIDs`. The SQLite adapter serializes that replacement, +rejects stale state, writes the new binding and audit event atomically, and +will not demote the final active direct owner. The application must still +authorize the administrator and bind any required fresh passkey assertion to +the organization, target user, target role, and expected IDs. + `access.Service` evaluates a permission against a complete resource scope: ```go diff --git a/organizations/organizations.go b/organizations/organizations.go index 4a29bcd..c9d7f64 100644 --- a/organizations/organizations.go +++ b/organizations/organizations.go @@ -107,6 +107,7 @@ type Repository interface { Invitations(context.Context, string, int) ([]Invitation, error) RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error + OrganizationMemberships(context.Context, string, int) ([]Membership, error) MembershipsForUser(context.Context, string) ([]Membership, error) TeamsForUser(context.Context, string, string) ([]Team, error) } @@ -331,6 +332,16 @@ func (service *Service) Memberships(ctx context.Context, userID string) ([]Membe return service.repository.MembershipsForUser(ctx, userID) } +// Members returns a bounded, stable list of active and suspended memberships +// for one organization. Authorization remains an application concern because +// the same storage primitive serves different organization policies. +func (service *Service) Members(ctx context.Context, organizationID string, limit int) ([]Membership, error) { + if !idPattern.MatchString(organizationID) || limit < 1 || limit > 2000 { + return nil, errors.New("organizations: invalid member query") + } + return service.repository.OrganizationMemberships(ctx, organizationID, limit) +} + func (service *Service) Teams(ctx context.Context, organizationID, userID string) ([]Team, error) { if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) { return nil, errors.New("organizations: invalid team query") diff --git a/organizations/organizations_test.go b/organizations/organizations_test.go index 016ae88..b78faf3 100644 --- a/organizations/organizations_test.go +++ b/organizations/organizations_test.go @@ -52,6 +52,7 @@ type repositoryStub struct { invitation Invitation invitationErr error acceptedUser string + members []Membership } func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership, _ AuditEvent) error { @@ -109,4 +110,7 @@ func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte func (*repositoryStub) MembershipsForUser(context.Context, string) ([]Membership, error) { return nil, nil } +func (repository *repositoryStub) OrganizationMemberships(context.Context, string, int) ([]Membership, error) { + return repository.members, nil +} func (*repositoryStub) TeamsForUser(context.Context, string, string) ([]Team, error) { return nil, nil }