Add atomic organization role administration
verify / verify (push) Successful in 3m39s

This commit is contained in:
2026-09-03 22:51:53 -04:00
parent 17bd9453e2
commit fe6bd94c9a
13 changed files with 549 additions and 12 deletions
+14
View File
@@ -2,6 +2,20 @@
# Changelog # 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 ## v0.1.0-preview.15 — 2026-09-03
- Permit applications to opt into an exact non-default HTTPS WebAuthn origin - Permit applications to opt into an exact non-default HTTPS WebAuthn origin
+3 -3
View File
@@ -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 deployment. Adopt one boundary at a time; Go compiles and links only the
packages you import. 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. > release. Linux is the maintained release platform.
## Why Web Foundations? ## Why Web Foundations?
@@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy.
Pin the preview in an application module: Pin the preview in an application module:
```bash ```bash
go get gamertan.com/web@v0.1.0-preview.15 go get gamertan.com/web@v0.1.0-preview.16
go mod verify go mod verify
``` ```
An application may name the first package it intends to adopt: An application may name the first package it intends to adopt:
```bash ```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 The version belongs to the `gamertan.com/web` module. See the
+83 -1
View File
@@ -18,6 +18,9 @@ import (
) )
var ( var (
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}$`) idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`) namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
) )
@@ -116,6 +119,8 @@ type Repository interface {
Grant(context.Context, Binding) error Grant(context.Context, Binding) error
Revoke(context.Context, string, string, time.Time) error Revoke(context.Context, string, string, time.Time) error
EffectiveBindings(context.Context, string, string) ([]Binding, 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 CreateBreakGlass(context.Context, BreakGlass, AuditEvent) error
ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error) ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error)
AppendAccessAudit(context.Context, AuditEvent) error AppendAccessAudit(context.Context, AuditEvent) error
@@ -125,6 +130,7 @@ type Repository interface {
type Options struct { type Options struct {
Random io.Reader Random io.Reader
Now func() time.Time Now func() time.Time
OwnerRole string
} }
type Service struct { type Service struct {
@@ -132,6 +138,7 @@ type Service struct {
policy Policy policy Policy
random io.Reader random io.Reader
now func() time.Time now func() time.Time
ownerRole string
} }
func New(repository Repository, policy Policy, options Options) (*Service, error) { 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 { if options.Now == nil {
options.Now = time.Now 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 { 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 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 { type Decision struct {
Allowed bool Allowed bool
Source string Source string
@@ -265,6 +333,20 @@ func randomID(random io.Reader) (string, error) {
return base64.RawURLEncoding.EncodeToString(value), nil 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 { func text(value string, limit int, emptyOK bool) bool {
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n") return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
} }
+60
View File
@@ -4,6 +4,8 @@ package access
import ( import (
"context" "context"
"errors"
"slices"
"strings" "strings"
"testing" "testing"
"time" "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 { type repositoryStub struct {
bindings []Binding bindings []Binding
breakGlass []BreakGlass breakGlass []BreakGlass
organizationUser []Binding
replacedExpected []string
replacedBinding Binding
replacedOwnerRole string
replacedAccessAudit AuditEvent
} }
func (*repositoryStub) SeedAccessPolicy(context.Context, Policy) error { return nil } 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) { func (repository *repositoryStub) EffectiveBindings(context.Context, string, string) ([]Binding, error) {
return repository.bindings, nil 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 { func (repository *repositoryStub) CreateBreakGlass(_ context.Context, grant BreakGlass, _ AuditEvent) error {
repository.breakGlass = []BreakGlass{grant} repository.breakGlass = []BreakGlass{grant}
return nil return nil
+154
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"slices"
"time" "time"
"gamertan.com/web/access" "gamertan.com/web/access"
@@ -134,6 +135,159 @@ func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userI
return result, rows.Err() 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 { 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 { if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID {
return errors.New("authsqlite: invalid break-glass event") return errors.New("authsqlite: invalid break-glass event")
+28
View File
@@ -261,6 +261,34 @@ func (store *Store) MembershipsForUser(ctx context.Context, userID string) ([]or
return result, rows.Err() 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) { func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID string) ([]organizations.Team, error) {
if !opaqueID(organizationID) || !opaqueID(userID) { if !opaqueID(organizationID) || !opaqueID(userID) {
return nil, errors.New("authsqlite: invalid team query") return nil, errors.New("authsqlite: invalid team query")
+166
View File
@@ -477,3 +477,169 @@ func TestInvitationAccessLifecycleAndLastOwnerProtection(t *testing.T) {
t.Fatalf("archived organization decision=%+v err=%v", decision, err) 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
}
+6
View File
@@ -54,3 +54,9 @@ application concern belongs in the shared module.
service now permits an explicit development port only when applications opt service now permits an explicit development port only when applications opt
in and the RP ID is `localhost` or reserved `.test`; production origins keep in and the RP ID is `localhost` or reserved `.test`; production origins keep
the original portless default. 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.
+1 -1
View File
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
module checksum: module checksum:
```bash ```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 go mod verify
``` ```
+1 -1
View File
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
and request the containing module at an exact version: and request the containing module at an exact version:
```bash ```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 Only imported packages are compiled and linked. The packages nevertheless
+12
View File
@@ -22,6 +22,18 @@ team membership can be removed independently. Configure `OwnerRole` when
constructing the service before exposing membership-removal operations. The constructing the service before exposing membership-removal operations. The
SQLite adapter then refuses to suspend or remove the final active direct owner. 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: `access.Service` evaluates a permission against a complete resource scope:
```go ```go
+11
View File
@@ -107,6 +107,7 @@ type Repository interface {
Invitations(context.Context, string, int) ([]Invitation, error) Invitations(context.Context, string, int) ([]Invitation, error)
RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error
AcceptInvitation(context.Context, [32]byte, 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) MembershipsForUser(context.Context, string) ([]Membership, error)
TeamsForUser(context.Context, string, string) ([]Team, 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) 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) { func (service *Service) Teams(ctx context.Context, organizationID, userID string) ([]Team, error) {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) { if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) {
return nil, errors.New("organizations: invalid team query") return nil, errors.New("organizations: invalid team query")
+4
View File
@@ -52,6 +52,7 @@ type repositoryStub struct {
invitation Invitation invitation Invitation
invitationErr error invitationErr error
acceptedUser string acceptedUser string
members []Membership
} }
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership, _ AuditEvent) error { 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) { func (*repositoryStub) MembershipsForUser(context.Context, string) ([]Membership, error) {
return nil, nil 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 } func (*repositoryStub) TeamsForUser(context.Context, string, string) ([]Team, error) { return nil, nil }