diff --git a/AI_DISCLOSURE.md b/AI_DISCLOSURE.md new file mode 100644 index 0000000..678e834 --- /dev/null +++ b/AI_DISCLOSURE.md @@ -0,0 +1,10 @@ + + +# AI assistance + +Codex has materially assisted with implementation, tests, documentation, and +integration work in this project, including the organization and access-control +extensions. Assistance is disclosed here rather than repeated in every commit +subject. Repository tests, review, and release evidence—not the use of a +particular tool—determine readiness. Automated checks do not imply that every +line has received independent human review. diff --git a/CHANGELOG.md b/CHANGELOG.md index ec48e99..389287b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ # Changelog +## v0.1.0-preview.23 — 2026-09-05 + +- Add atomic direct organization role sets with optimistic binding IDs, current + direct-owner authorization, last-owner protection, and a single audit. Roles + may be combined without changing narrower or team grants. +- Add bounded multiple-role invitations and opt-in owner-managed invitation + policy. Persist the required grantor authority and recheck it at acceptance, + together with active, fully registered users and recipient email. Suspended + members cannot reactivate themselves by accepting an older invitation. +- Add SQLite schema 10 for invitation role sets and stored owner authority. + Legacy single-role data remains readable after explicit migration; older + schema-9 applications are not approved writers of the migrated database. + Custom repositories must implement the role-set extensions before exposing + these operations; there is no non-atomic fallback. +- Include the owned-organization implementation and tests in the public-source + export, and compile the exported tree to catch incomplete source distributions. +- Cover competing changes and invitation acceptance, failure rollback, stale + owners, unsupported adapters, and migration of legacy invitations. + ## v0.1.0-preview.22 — 2026-09-04 - Add `organizations.CreateOwnedOrganization` for atomic creation of an existing diff --git a/README.md b/README.md index 62a0b37..fae83fa 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.22`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.23`. 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.22 +go get gamertan.com/web@v0.1.0-preview.23 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.22 +go get gamertan.com/web/requestmeta@v0.1.0-preview.23 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/access/role_sets.go b/access/role_sets.go new file mode 100644 index 0000000..3a0e6e7 --- /dev/null +++ b/access/role_sets.go @@ -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 +} diff --git a/access/role_sets_test.go b/access/role_sets_test.go new file mode 100644 index 0000000..0e591eb --- /dev/null +++ b/access/role_sets_test.go @@ -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") + } +} diff --git a/authsqlite/access.go b/authsqlite/access.go index 40874b2..1665967 100644 --- a/authsqlite/access.go +++ b/authsqlite/access.go @@ -167,9 +167,28 @@ func (store *Store) OrganizationUserBindings(ctx context.Context, organizationID } 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") + return store.replaceOrganizationUserRoles(ctx, expected, []access.Binding{replacement}, ownerRole, audit, false) +} + +func (store *Store) ReplaceOrganizationUserRoles(ctx context.Context, expected []string, replacements []access.Binding, ownerRole string, audit access.AuditEvent) error { + return store.replaceOrganizationUserRoles(ctx, expected, replacements, ownerRole, audit, true) +} + +func (store *Store) replaceOrganizationUserRoles(ctx context.Context, expected []string, replacements []access.Binding, ownerRole string, audit access.AuditEvent, requireOwner bool) error { + if len(replacements) < 1 || len(replacements) > 16 { + return errors.New("authsqlite: invalid organization role set") } + replacement := replacements[0] + roles := make([]string, 0, len(replacements)) + ids := make(map[string]bool, len(replacements)) + for _, value := range replacements { + if !validOrganizationRoleReplacement(expected, value, ownerRole, audit) || value.SubjectID != replacement.SubjectID || value.Scope != replacement.Scope || value.GrantedBy != replacement.GrantedBy || !value.GrantedAt.Equal(replacement.GrantedAt) || ids[value.ID] || slices.Contains(roles, value.Role) { + return errors.New("authsqlite: invalid organization role set") + } + roles = append(roles, value.Role) + ids[value.ID] = true + } + slices.Sort(roles) tx, err := store.db.BeginTx(ctx, nil) if err != nil { return err @@ -183,7 +202,7 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected [] 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) + AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy) if err != nil { return err } @@ -195,7 +214,7 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected [] 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' + JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0 WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil { return err } @@ -231,10 +250,11 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected [] if !slices.Equal(currentIDs, expected) { return access.ErrRoleChangeConflict } - if len(currentRoles) == 1 && currentRoles[0] == replacement.Role { + slices.Sort(currentRoles) + if slices.Equal(currentRoles, roles) { return access.ErrRoleUnchanged } - if replacement.Role == ownerRole || slices.Contains(currentRoles, ownerRole) { + if requireOwner || slices.Contains(roles, ownerRole) || slices.Contains(currentRoles, ownerRole) { actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, replacement.Scope.OrganizationID, replacement.GrantedBy, ownerRole) if ownerErr != nil { return ownerErr @@ -243,12 +263,12 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected [] return access.ErrOwnerAuthority } } - if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) { + if !slices.Contains(roles, 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' + JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0 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 { @@ -265,13 +285,15 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected [] 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") + for _, value := range replacements { + 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=?`, value.ID, value.Scope.OrganizationID, value.SubjectID, value.Role, value.GrantedBy, value.GrantedAt.Unix(), value.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 diff --git a/authsqlite/organizations.go b/authsqlite/organizations.go index 887701a..68d4e3b 100644 --- a/authsqlite/organizations.go +++ b/authsqlite/organizations.go @@ -7,6 +7,8 @@ import ( "database/sql" "encoding/json" "errors" + "slices" + "strconv" "time" "gamertan.com/web/organizations" @@ -123,10 +125,28 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or return nil } +func (store *Store) CreateInvitationWithRoles(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error { + return store.CreateInvitation(ctx, invitation, ownerRole, audit) +} + func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error { if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || ownerRole != "" && !safeName(ownerRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) { return errors.New("authsqlite: invalid invitation") } + roles, err := invitation.RoleNames() + if err != nil { + return err + } + if invitation.RequiredOwnerRole != "" && invitation.RequiredOwnerRole != ownerRole || audit.ActorUserID != invitation.InvitedByUserID || audit.Action != "invitation.create" || audit.ResourceType != "invitation" || audit.ResourceID != invitation.ID { + return errors.New("authsqlite: invalid invitation authority") + } + if ownerRole != "" && slices.Contains(roles, ownerRole) { + invitation.RequiredOwnerRole = ownerRole + } + rolesJSON, err := json.Marshal(invitation.DirectRoles) + if err != nil { + return err + } teamIDs, err := json.Marshal(invitation.TeamIDs) if err != nil { return err @@ -139,8 +159,8 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil { return err } - if ownerRole != "" && invitation.DirectRole == ownerRole { - actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, ownerRole) + if invitation.RequiredOwnerRole != "" { + actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, invitation.RequiredOwnerRole) if ownerErr != nil { return ownerErr } @@ -151,9 +171,18 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil { return err } - result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at,id,direct_role,team_ids_json) - SELECT ?,?,?,?,?,?,?,?,? FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id - WHERE m.organization_id=? AND m.user_id=? AND m.status='active' AND o.status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.ID, invitation.DirectRole, teamIDs, invitation.OrganizationID, invitation.InvitedByUserID) + for _, role := range roles { + var count int + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_roles WHERE name=?`, role).Scan(&count); err != nil { + return err + } + if count != 1 { + return errors.New("authsqlite: invitation role has not been seeded") + } + } + result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at,id,direct_role,team_ids_json,direct_roles_json,required_owner_role) + SELECT ?,?,?,?,?,?,?,?,?,?,? FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id + WHERE m.organization_id=? AND m.user_id=? AND m.status='active' AND o.status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.ID, invitation.DirectRole, teamIDs, rolesJSON, invitation.RequiredOwnerRole, invitation.OrganizationID, invitation.InvitedByUserID) if err != nil { return err } @@ -172,8 +201,8 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now } var invitation organizations.Invitation var created, expires int64 - var teamIDs []byte - err := store.db.QueryRowContext(ctx, `SELECT id,organization_id,email_normalized,invited_by_user_id,direct_role,team_ids_json,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.ID, &invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &invitation.DirectRole, &teamIDs, &created, &expires) + var teamIDs, rolesJSON []byte + err := store.db.QueryRowContext(ctx, `SELECT id,organization_id,email_normalized,invited_by_user_id,direct_role,team_ids_json,direct_roles_json,required_owner_role,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.ID, &invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &invitation.DirectRole, &teamIDs, &rolesJSON, &invitation.RequiredOwnerRole, &created, &expires) if errors.Is(err, sql.ErrNoRows) { return organizations.Invitation{}, organizations.ErrInvitationNotFound } @@ -184,13 +213,20 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now if err = json.Unmarshal(teamIDs, &invitation.TeamIDs); err != nil || !validInvitationTeamIDs(invitation.TeamIDs) { return organizations.Invitation{}, organizations.ErrInvitationNotFound } + if !decodeInvitationRoles(&invitation, rolesJSON) { + return organizations.Invitation{}, organizations.ErrInvitationNotFound + } invitation.CreatedAt = time.Unix(created, 0).UTC() invitation.ExpiresAt = time.Unix(expires, 0).UTC() return invitation, nil } func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time, audit organizations.AuditEvent) error { - if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) { + return store.AcceptInvitationWithRoles(ctx, digest, userID, "", acceptedAt, audit) +} + +func (store *Store) AcceptInvitationWithRoles(ctx context.Context, digest [32]byte, userID, ownerRole string, acceptedAt time.Time, audit organizations.AuditEvent) error { + if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) || audit.ActorUserID != userID || ownerRole != "" && !safeName(ownerRole) { return organizations.ErrInvitationNotFound } tx, err := store.db.BeginTx(ctx, nil) @@ -198,15 +234,44 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI return err } defer tx.Rollback() - var invitationID, organizationID, directRole, invitedBy string - var teamIDsJSON []byte - err = tx.QueryRowContext(ctx, `SELECT i.id,i.organization_id,i.direct_role,i.team_ids_json,i.invited_by_user_id FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized JOIN gwf_organizations o ON o.id=i.organization_id AND o.status='active' WHERE i.token_hash=? AND i.used_at IS NULL AND i.revoked_at IS NULL AND i.expires_at>?`, userID, digest[:], acceptedAt.Unix()).Scan(&invitationID, &organizationID, &directRole, &teamIDsJSON, &invitedBy) + // Serialize acceptance before reading token state, including competing users. + if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET expires_at=expires_at WHERE token_hash=?`, digest[:]); err != nil { + return err + } + var invitationID, organizationID, directRole, invitedBy, requiredOwnerRole string + var teamIDsJSON, rolesJSON []byte + err = tx.QueryRowContext(ctx, `SELECT i.id,i.organization_id,i.direct_role,i.team_ids_json,i.invited_by_user_id,i.direct_roles_json,i.required_owner_role FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized AND u.status='active' AND u.registration_pending=0 JOIN gwf_organizations o ON o.id=i.organization_id AND o.status='active' WHERE i.token_hash=? AND i.used_at IS NULL AND i.revoked_at IS NULL AND i.expires_at>? AND NOT EXISTS (SELECT 1 FROM gwf_organization_memberships m WHERE m.organization_id=i.organization_id AND m.user_id=u.id AND m.status<>'active')`, userID, digest[:], acceptedAt.Unix()).Scan(&invitationID, &organizationID, &directRole, &teamIDsJSON, &invitedBy, &rolesJSON, &requiredOwnerRole) if errors.Is(err, sql.ErrNoRows) { return organizations.ErrInvitationNotFound } if err != nil { return err } + invitation := organizations.Invitation{DirectRole: directRole, RequiredOwnerRole: requiredOwnerRole} + if !decodeInvitationRoles(&invitation, rolesJSON) { + return organizations.ErrInvitationNotFound + } + roles, _ := invitation.RoleNames() + if audit.OrganizationID != organizationID || audit.ResourceType != "invitation" || audit.ResourceID != invitationID || audit.Action != "invitation.accept" { + return organizations.ErrInvitationNotFound + } + // Stored authority survives which application service receives the link. + // The caller's owner role also protects pre-schema-10 single-role invitations. + if requiredOwnerRole == "" && ownerRole != "" && slices.Contains(roles, ownerRole) { + requiredOwnerRole = ownerRole + } + if err = lockActiveMembershipActor(ctx, tx, organizationID, invitedBy); err != nil { + return err + } + if requiredOwnerRole != "" { + isOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, invitedBy, requiredOwnerRole) + if ownerErr != nil { + return ownerErr + } + if !isOwner { + return organizations.ErrOwnerAuthority + } + } if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,'active',?) ON CONFLICT(organization_id,user_id) DO UPDATE SET status='active'`, organizationID, userID, acceptedAt.Unix()); err != nil { return err } @@ -222,11 +287,12 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI return err } } - if directRole != "" { - if !safeName(directRole) { - return organizations.ErrInvitationNotFound + for i, role := range roles { + bindingID := "invite-" + invitationID + if len(invitation.DirectRoles) > 0 { + bindingID += "-" + strconv.Itoa(i) } - 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=?`, "invite-"+invitationID, organizationID, userID, directRole, invitedBy, acceptedAt.Unix(), directRole) + 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=?`, bindingID, organizationID, userID, role, invitedBy, acceptedAt.Unix(), role) if err != nil { return err } @@ -241,9 +307,6 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI if changed, _ := result.RowsAffected(); changed != 1 { return organizations.ErrInvitationNotFound } - if organizationID != audit.OrganizationID { - return organizations.ErrInvitationNotFound - } if err = appendOrganizationAudit(ctx, tx, audit); err != nil { return err } @@ -603,7 +666,7 @@ func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID, 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')`, organizationID, actorUserID, organizationID, actorUserID) + AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)`, organizationID, actorUserID, organizationID, actorUserID) if err != nil { return err } @@ -665,7 +728,7 @@ func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, o 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' + JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0 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`, organizationID, userID, ownerRole).Scan(&otherActiveOwners); err != nil { @@ -696,7 +759,7 @@ func (store *Store) Invitations(ctx context.Context, organizationID string, limi if !opaqueID(organizationID) || limit < 1 || limit > 1000 { return nil, errors.New("authsqlite: invalid invitation query") } - rows, err := store.db.QueryContext(ctx, `SELECT id,email_normalized,invited_by_user_id,direct_role,team_ids_json,created_at,expires_at,COALESCE(used_at,0),COALESCE(revoked_at,0) FROM gwf_organization_invitations WHERE organization_id=? ORDER BY created_at DESC LIMIT ?`, organizationID, limit) + rows, err := store.db.QueryContext(ctx, `SELECT id,email_normalized,invited_by_user_id,direct_role,team_ids_json,direct_roles_json,required_owner_role,created_at,expires_at,COALESCE(used_at,0),COALESCE(revoked_at,0) FROM gwf_organization_invitations WHERE organization_id=? ORDER BY created_at DESC,id LIMIT ?`, organizationID, limit) if err != nil { return nil, err } @@ -705,13 +768,16 @@ func (store *Store) Invitations(ctx context.Context, organizationID string, limi for rows.Next() { var value organizations.Invitation var created, expires, used, revoked int64 - var teamIDs []byte - if err = rows.Scan(&value.ID, &value.Email, &value.InvitedByUserID, &value.DirectRole, &teamIDs, &created, &expires, &used, &revoked); err != nil { + var teamIDs, rolesJSON []byte + if err = rows.Scan(&value.ID, &value.Email, &value.InvitedByUserID, &value.DirectRole, &teamIDs, &rolesJSON, &value.RequiredOwnerRole, &created, &expires, &used, &revoked); err != nil { return nil, err } if json.Unmarshal(teamIDs, &value.TeamIDs) != nil || !validInvitationTeamIDs(value.TeamIDs) { return nil, errors.New("authsqlite: stored invitation is invalid") } + if !decodeInvitationRoles(&value, rolesJSON) { + return nil, errors.New("authsqlite: stored invitation roles are invalid") + } value.OrganizationID = organizationID value.CreatedAt, value.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC() if used != 0 { @@ -742,6 +808,14 @@ func validInvitationTeamIDs(teamIDs []string) bool { return true } +func decodeInvitationRoles(invitation *organizations.Invitation, raw []byte) bool { + if len(raw) > 4096 || json.Unmarshal(raw, &invitation.DirectRoles) != nil || invitation.RequiredOwnerRole != "" && !safeName(invitation.RequiredOwnerRole) { + return false + } + _, err := invitation.RoleNames() + return err == nil +} + func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID string, teamIDs []string) error { for _, teamID := range teamIDs { var count int @@ -767,15 +841,23 @@ func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invita if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil { return err } - var directRole string - if err = tx.QueryRowContext(ctx, `SELECT direct_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&directRole); err != nil { + var invitation organizations.Invitation + var rolesJSON []byte + if err = tx.QueryRowContext(ctx, `SELECT direct_role,direct_roles_json,required_owner_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&invitation.DirectRole, &rolesJSON, &invitation.RequiredOwnerRole); err != nil { if errors.Is(err, sql.ErrNoRows) { return organizations.ErrInvitationNotFound } return err } - if ownerRole != "" && directRole == ownerRole { - actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, ownerRole) + if !decodeInvitationRoles(&invitation, rolesJSON) { + return organizations.ErrInvitationNotFound + } + roles, _ := invitation.RoleNames() + if invitation.RequiredOwnerRole == "" && ownerRole != "" && slices.Contains(roles, ownerRole) { + invitation.RequiredOwnerRole = ownerRole + } + if invitation.RequiredOwnerRole != "" { + actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, invitation.RequiredOwnerRole) if ownerErr != nil { return ownerErr } diff --git a/authsqlite/role_sets_test.go b/authsqlite/role_sets_test.go new file mode 100644 index 0000000..f804287 --- /dev/null +++ b/authsqlite/role_sets_test.go @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "errors" + "slices" + "testing" + "time" + + "gamertan.com/web/access" + "gamertan.com/web/organizations" +) + +type roleSetFixture struct { + store *Store + access *access.Service + organizations *organizations.Service + org organizations.Organization + now time.Time +} + +const roleOwner = "customer-12345" +const roleMember = "member-12345678" + +func newRoleSetFixture(t *testing.T) roleSetFixture { + t.Helper() + store, _, policy, input := ownedOrganizationFixture(t) + policy.Roles["buyer"] = "Buyer" + policy.Roles["billing"] = "Billing manager" + policy.Roles["member"] = "Member" + policy.Permissions["billing.manage"] = "Manage billing" + policy.Grants["buyer"] = []string{"customer.purchase"} + policy.Grants["billing"] = []string{"billing.manage"} + policy.Grants["member"] = nil + now := time.Unix(2100, 0).UTC() + accessService, err := access.New(store, policy, access.Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + if err = accessService.Seed(t.Context()); err != nil { + t.Fatal(err) + } + service, err := organizations.New(store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true, Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + org, err := service.CreateOwnedOrganization(t.Context(), input) + if err != nil { + t.Fatal(err) + } + if _, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,registration_pending,created_at,updated_at) + VALUES(?,?,?,?,?,?,'active',0,2000,2000)`, roleMember, "member", "member", "member@example.test", "member@example.test", "Member"); err != nil { + t.Fatal(err) + } + return roleSetFixture{store: store, access: accessService, organizations: service, org: org, now: now} +} + +func (f roleSetFixture) invite(t *testing.T, roles ...string) (string, organizations.Invitation) { + t.Helper() + raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRoles: roles, Lifetime: time.Hour, RequestID: "request-invite"}) + if err != nil { + t.Fatal(err) + } + return raw, invitation +} + +func (f roleSetFixture) addMember(t *testing.T) { + t.Helper() + raw, _ := f.invite(t, "member") + if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil { + t.Fatal(err) + } +} + +func (f roleSetFixture) bindings(t *testing.T, user string) ([]string, []string) { + t.Helper() + bindings, err := f.access.OrganizationUserBindings(t.Context(), f.org.ID, 100) + if err != nil { + t.Fatal(err) + } + var ids, roles []string + for _, binding := range bindings { + if binding.SubjectID == user { + ids = append(ids, binding.ID) + roles = append(roles, binding.Role) + } + } + slices.Sort(ids) + slices.Sort(roles) + return ids, roles +} + +func (f roleSetFixture) change(t *testing.T, actor, target string, roles ...string) error { + t.Helper() + ids, _ := f.bindings(t, target) + _, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, RequestID: "request-roles", Roles: roles, ExpectedBindingIDs: ids}) + return err +} + +func TestRoleSetCombinesCapabilitiesWithoutNarrowGrantChanges(t *testing.T) { + f := newRoleSetFixture(t) + f.addMember(t) + project, err := f.organizations.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: f.org.ID, Slug: "project", Name: "Project"}) + if err != nil { + t.Fatal(err) + } + narrow, err := f.access.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: roleMember, Role: "member", Scope: access.Scope{OrganizationID: f.org.ID, ProjectID: project.ID}, GrantedBy: roleOwner}) + if err != nil { + t.Fatal(err) + } + if err = f.change(t, roleOwner, roleMember, "buyer", "billing"); err != nil { + t.Fatal(err) + } + _, roles := f.bindings(t, roleMember) + if !slices.Equal(roles, []string{"billing", "buyer"}) { + t.Fatalf("roles=%v", roles) + } + for _, permission := range []string{"customer.purchase", "billing.manage"} { + decision, err := f.access.Authorize(t.Context(), roleMember, access.Scope{OrganizationID: f.org.ID}, permission) + if err != nil || !decision.Allowed { + t.Fatalf("permission=%s allowed=%v err=%v", permission, decision.Allowed, err) + } + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=? AND revoked_at IS NULL`, narrow.ID, 1) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1) + if err = f.change(t, roleOwner, roleMember, "billing", "buyer"); !errors.Is(err, access.ErrRoleUnchanged) { + t.Fatalf("unchanged=%v", err) + } + if err = f.change(t, roleMember, roleMember, "member"); !errors.Is(err, access.ErrOwnerAuthority) { + t.Fatalf("non-owner bulk change=%v", err) + } + if err = f.change(t, roleOwner, roleOwner, "billing", "buyer"); !errors.Is(err, access.ErrLastOwner) { + t.Fatalf("last owner=%v", err) + } + if err = f.change(t, roleOwner, roleMember, "customer.owner", "billing"); err != nil { + t.Fatal(err) + } + if err = f.change(t, roleMember, roleOwner, "buyer"); err != nil { + t.Fatal(err) + } + if err = f.change(t, roleMember, roleMember, "buyer"); !errors.Is(err, access.ErrLastOwner) { + t.Fatalf("new last owner=%v", err) + } +} + +func TestRoleSetConcurrentChangesHaveOneWinner(t *testing.T) { + f := newRoleSetFixture(t) + f.addMember(t) + ids, _ := f.bindings(t, roleMember) + start := make(chan struct{}) + results := make(chan error, 2) + for range 2 { + go func() { + <-start + _, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, Roles: []string{"buyer", "billing"}, ExpectedBindingIDs: ids}) + results <- err + }() + } + close(start) + success, conflict := 0, 0 + for range 2 { + err := <-results + switch { + case err == nil: + success++ + case errors.Is(err, access.ErrRoleChangeConflict): + conflict++ + default: + t.Fatalf("concurrent error=%v", err) + } + } + if success != 1 || conflict != 1 { + t.Fatalf("success=%d conflict=%d", success, conflict) + } + _, roles := f.bindings(t, roleMember) + if !slices.Equal(roles, []string{"billing", "buyer"}) { + t.Fatalf("roles=%v", roles) + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1) +} + +func TestRoleSetRollsBackRevocationAndPartialInsert(t *testing.T) { + for _, stage := range []string{"second binding", "audit", "missing role"} { + t.Run(stage, func(t *testing.T) { + f := newRoleSetFixture(t) + f.addMember(t) + before, _ := f.bindings(t, roleMember) + var statement string + switch stage { + case "second binding": + statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'write failure'); END` + case "audit": + statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='access.role.replace' BEGIN SELECT RAISE(ABORT,'audit failure'); END` + case "missing role": + statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'` + } + if _, err := f.store.db.Exec(statement); err != nil { + t.Fatal(err) + } + if err := f.change(t, roleOwner, roleMember, "billing", "buyer"); err == nil { + t.Fatal("write failure accepted") + } + after, roles := f.bindings(t, roleMember) + if !slices.Equal(before, after) || !slices.Equal(roles, []string{"member"}) { + t.Fatalf("after=%v roles=%v", after, roles) + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 0) + }) + } +} + +func TestRoleInvitationPreservesRolesAuthorityAndSingleUse(t *testing.T) { + f := newRoleSetFixture(t) + raw, invitation := f.invite(t, "buyer", "billing") + stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now) + if err != nil { + t.Fatal(err) + } + if stored.RequiredOwnerRole != "customer.owner" || stored.DirectRole != "" || !slices.Equal(stored.DirectRoles, []string{"billing", "buyer"}) { + t.Fatalf("roles=%v authority=%q", stored.DirectRoles, stored.RequiredOwnerRole) + } + listed, err := f.organizations.Invitations(t.Context(), f.org.ID, 10) + if err != nil || len(listed) != 1 || !slices.Equal(listed[0].DirectRoles, stored.DirectRoles) { + t.Fatalf("listed=%v err=%v", listed, err) + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.create' AND request_id='request-invite'`, invitation.ID, 1) + if err = f.organizations.AcceptInvitation(t.Context(), raw, roleOwner); err == nil { + t.Fatal("wrong email accepted") + } + start := make(chan struct{}) + results := make(chan error, 2) + for range 2 { + go func() { <-start; results <- f.organizations.AcceptInvitation(t.Context(), raw, roleMember) }() + } + close(start) + success := 0 + for range 2 { + if err := <-results; err == nil { + success++ + } else if !errors.Is(err, organizations.ErrInvitationNotFound) { + t.Fatalf("accept error=%v", err) + } + } + if success != 1 { + t.Fatalf("accepted=%d", success) + } + _, roles := f.bindings(t, roleMember) + if !slices.Equal(roles, []string{"billing", "buyer"}) { + t.Fatalf("roles=%v", roles) + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 1) + if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) { + t.Fatalf("replay=%v", err) + } +} + +func TestRoleInvitationRechecksGrantorAndRecipient(t *testing.T) { + for _, mutation := range []string{ + `UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id='customer-12345'`, + `UPDATE gwf_organization_memberships SET status='suspended' WHERE user_id='customer-12345'`, + `UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`, + `UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`, + `DELETE FROM gwf_organization_memberships WHERE user_id='customer-12345'`, + `UPDATE gwf_users SET status='disabled' WHERE id='member-12345678'`, + `UPDATE gwf_users SET registration_pending=1 WHERE id='member-12345678'`, + `UPDATE gwf_organizations SET status='archived'`, + `UPDATE gwf_organization_invitations SET expires_at=2100`, + `UPDATE gwf_organization_invitations SET revoked_at=2100`, + `UPDATE gwf_organization_invitations SET direct_roles_json='["buyer","buyer"]'`, + } { + t.Run(mutation, func(t *testing.T) { + f := newRoleSetFixture(t) + raw, invitation := f.invite(t, "buyer", "billing") + if _, err := f.store.db.Exec(mutation); err != nil { + t.Fatal(err) + } + // Stored authority still applies through a differently configured service. + other, err := organizations.New(f.store, organizations.Options{Now: func() time.Time { return f.now }}) + if err != nil { + t.Fatal(err) + } + if err = other.AcceptInvitation(t.Context(), raw, roleMember); err == nil { + t.Fatal("stale or invalid authority accepted") + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0) + }) + } +} + +func TestRoleInvitationRejectsImplicitReactivationAndNonOwnerManagement(t *testing.T) { + f := newRoleSetFixture(t) + f.addMember(t) + if _, _, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "new@example.test", InvitedByUserID: roleMember, DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) { + t.Fatalf("member invite=%v", err) + } + raw, invitation := f.invite(t, "buyer", "billing") + if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleMember, "request-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) { + t.Fatalf("member revoke=%v", err) + } + if err := f.organizations.SetMembershipStatus(t.Context(), f.org.ID, roleMember, "suspended", roleOwner, "request-suspend"); err != nil { + t.Fatal(err) + } + if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil { + t.Fatal("invitation reactivated suspended member") + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='suspended'`, roleMember, 1) + if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleOwner, "request-revoke-owner"); err != nil { + t.Fatal(err) + } +} + +func TestRoleInvitationAcceptanceRollsBackEveryWrite(t *testing.T) { + for _, stage := range []string{"membership", "binding", "audit", "missing role"} { + t.Run(stage, func(t *testing.T) { + f := newRoleSetFixture(t) + raw, invitation := f.invite(t, "buyer", "billing") + var statement string + switch stage { + case "membership": + statement = `CREATE TRIGGER fail_member BEFORE INSERT ON gwf_organization_memberships BEGIN SELECT RAISE(ABORT,'membership failure'); END` + case "binding": + statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'binding failure'); END` + case "audit": + statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.accept' BEGIN SELECT RAISE(ABORT,'audit failure'); END` + case "missing role": + statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'` + } + if _, err := f.store.db.Exec(statement); err != nil { + t.Fatal(err) + } + if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil { + t.Fatal("partial acceptance succeeded") + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 0) + }) + } +} + +func TestRoleInvitationMigrationPreservesLegacyAndRequiresExplicitMigration(t *testing.T) { + f := newRoleSetFixture(t) + raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRole: "customer.owner", Lifetime: time.Hour}) + if err != nil { + t.Fatal(err) + } + // Reconstruct the previous invitation schema in this disposable database. + if _, err = f.store.db.Exec(`ALTER TABLE gwf_organization_invitations DROP COLUMN direct_roles_json; + ALTER TABLE gwf_organization_invitations DROP COLUMN required_owner_role; + DELETE FROM gamertan_web_migrations WHERE version=10`); err != nil { + t.Fatal(err) + } + if err = f.store.RequireCurrentSchema(t.Context()); err == nil { + t.Fatal("old schema accepted without migration") + } + for range 2 { + if err = f.store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + } + if err = f.store.RequireCurrentSchema(t.Context()); err != nil { + t.Fatal(err) + } + stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now) + if err != nil || stored.DirectRole != "customer.owner" || len(stored.DirectRoles) != 0 || stored.RequiredOwnerRole != "" { + t.Fatalf("legacy changed: %+v err=%v", stored, err) + } + if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id=?`, roleOwner); err != nil { + t.Fatal(err) + } + if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrOwnerAuthority) { + t.Fatalf("legacy owner authority=%v", err) + } + if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=NULL WHERE subject_id=?`, roleOwner); err != nil { + t.Fatal(err) + } + if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil { + t.Fatal(err) + } + ids, roles := f.bindings(t, roleMember) + if !slices.Equal(roles, []string{"customer.owner"}) || !slices.Equal(ids, []string{"invite-" + invitation.ID}) { + t.Fatalf("legacy IDs=%v roles=%v", ids, roles) + } +} + +func TestRoleInvitationUsesAdvancingClockAndBoundAudit(t *testing.T) { + f := newRoleSetFixture(t) + service, err := organizations.New(f.store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true}) + if err != nil { + t.Fatal(err) + } + raw, invitation, err := service.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: []string{"billing", "buyer"}, Lifetime: time.Hour}) + if err != nil { + t.Fatal(err) + } + audit := organizations.AuditEvent{ID: "audit-accept-12345", OrganizationID: f.org.ID, ActorUserID: roleMember, Action: "invitation.accept", ResourceType: "invitation", ResourceID: invitation.ID, Summary: "Invitation accepted", CreatedAt: time.Now().UTC()} + for _, field := range []string{"actor", "resource", "action"} { + bad := audit + switch field { + case "actor": + bad.ActorUserID = roleOwner + case "resource": + bad.ResourceID = "invitation-other" + case "action": + bad.Action = "invitation.create" + } + if err = f.store.AcceptInvitationWithRoles(t.Context(), invitation.Digest, roleMember, "customer.owner", time.Now().UTC(), bad); err == nil { + t.Fatalf("mismatched audit %s accepted", field) + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0) + } + if err = service.AcceptInvitation(t.Context(), raw, roleMember); err != nil { + t.Fatalf("real clock acceptance=%v", err) + } +} + +func TestRoleInvitationCreationIsAtomicAndRejectsUnknownRole(t *testing.T) { + for _, fail := range []string{"unknown role", "audit"} { + t.Run(fail, func(t *testing.T) { + f := newRoleSetFixture(t) + roles := []string{"billing", "buyer"} + if fail == "unknown role" { + roles = append(roles, "unknown") + } else if _, err := f.store.db.Exec(`CREATE TRIGGER fail_invite BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.create' BEGIN SELECT RAISE(ABORT,'audit failure'); END`); err != nil { + t.Fatal(err) + } + raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: roles, Lifetime: time.Hour}) + if err == nil || raw != "" || invitation.ID != "" { + t.Fatal("failed creation returned invitation") + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE organization_id=?`, f.org.ID, 0) + }) + } +} diff --git a/authsqlite/store.go b/authsqlite/store.go index 7f3ba7f..86172d2 100644 --- a/authsqlite/store.go +++ b/authsqlite/store.go @@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) { return store, nil } -const SchemaVersion = 9 +const SchemaVersion = 10 func (store *Store) CurrentSchema(ctx context.Context) (int, error) { var exists int @@ -189,6 +189,8 @@ func (store *Store) Migrate(ctx context.Context) error { {"gwf_organization_invitations", "revoked_at", `INTEGER`}, {"gwf_organization_invitations", "direct_role", `TEXT NOT NULL DEFAULT ''`}, {"gwf_organization_invitations", "team_ids_json", `BLOB NOT NULL DEFAULT '[]'`}, + {"gwf_organization_invitations", "direct_roles_json", `BLOB NOT NULL DEFAULT '[]'`}, + {"gwf_organization_invitations", "required_owner_role", `TEXT NOT NULL DEFAULT ''`}, } { exists, columnErr := sqliteColumnExists(ctx, tx, migration.table, migration.column) if columnErr != nil { @@ -239,6 +241,9 @@ func (store *Store) Migrate(ctx context.Context) error { if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(9,?)`, time.Now().UTC().Unix()); err != nil { return err } + if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(10,?)`, time.Now().UTC().Unix()); err != nil { + return err + } return tx.Commit() } diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index 1263640..e73ec0b 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -8,6 +8,19 @@ application concern belongs in the shared module. ## Gamertan accounts and commerce +- A customer may need both purchasing and billing access. Replacing one role at + a time would create partial permission states and misleading audit history. + The role-set extension commits all direct roles together with optimistic + binding IDs and current owner authority. Multiple-role invitations carry the + same combination atomically, with stored owner-managed policy rechecked when + accepted. SQLite tests cover concurrent winners, write rollback, demoted or + removed grantors, and attempted implicit reactivation of suspended members. + This adds schema 10; application vocabulary, allowed roles, invitation delivery, + ordinary-customer authentication, and UI/API commands remain application-owned. +- The public export allowlist omitted the owned-organization files introduced + in preview 22. Including them and building the exported tree tests the actual + distribution boundary rather than only comparing its path list with itself. + - Shared business purchasing exposed the difference between an initial member and an initial RBAC owner. The historical organization creation method commits membership but no access binding. The new `CreateOwnedOrganization` extension diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 62963fb..4eae2f1 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.22 +go get gamertan.com/web/requestmeta@v0.1.0-preview.23 go mod verify ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index 377fdee..3bfc778 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.22 +go get gamertan.com/web/requestmeta@v0.1.0-preview.23 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/ORGANIZATIONS.md b/docs/ORGANIZATIONS.md index b7c4e06..619813a 100644 --- a/docs/ORGANIZATIONS.md +++ b/docs/ORGANIZATIONS.md @@ -8,17 +8,33 @@ environments; environments own application services. Teams are optional groups of active organization members. `organizations.Service` creates those resources and issues digest-backed, -expiring, single-use invitations. An invitation may carry one direct role and -up to sixteen reviewed team memberships. Acceptance verifies that the +expiring, single-use invitations. An invitation may carry up to sixteen direct +roles and sixteen reviewed team memberships. Acceptance verifies that the authenticated user's normalized email matches and applies the membership, -role, teams, consumption marker, and audit event in one transaction. -When `OwnerRole` is configured, creating or revoking an invitation carrying -that role additionally requires a current active direct owner inside the same -SQLite transaction. A broad access-management permission may administer -ordinary invitations but cannot create or cancel owner access. +roles, teams, consumption marker, and audit event in one transaction. The +recipient and issuing member must remain active, fully registered users of an +active organization; a suspended recipient cannot use an invitation as implicit +reactivation. Duplicate or concurrent acceptance consumes the token only once. +When `OwnerRole` is configured, invitations granting that role require a current +direct owner at creation and acceptance, and an owner for revocation. Set +`OwnerManagedInvitations: true` to apply that rule to every invitation, including +ordinary member invitations. Stored `RequiredOwnerRole` preserves the boundary +even when a link reaches another application service with different options. +A broad access-management permission can still administer ordinary invitations +when owner-managed policy is disabled, but cannot create or cancel owner access. Applications own invitation pages, email or out-of-band delivery, active-source checks before archival, and account recovery. +Use `InviteWithAccess.DirectRoles` for combinations and `RequestID` for the +creation audit correlation. `DirectRole` remains the legacy single-role form; +supplying both is rejected, not merged. The service copies and sorts role arrays +and rejects duplicates or unknown/unseeded roles before persistence. Repository +adapters implement `RoleInvitationRepository` to store and enforce role-set and +owner requirements atomically. An unsupported adapter returns +`ErrRoleInvitationUnsupported`; it must not issue a partly effective invitation. +The application restricts which roles may be offered and authenticates the actor; +never accept the owner-role policy or actor identity from submitted fields. + ## Creating an organization with an owner For an existing authenticated user creating a business, use @@ -36,6 +52,7 @@ a customer organization grants no authority in any other organization. ```go customers, err := organizations.New(store, organizations.Options{ OwnerRole: "customer.owner", // Application-defined, already seeded. + OwnerManagedInvitations: true, }) if err != nil { return err @@ -83,6 +100,37 @@ 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. +For combinations such as Buyer plus Billing Manager, use +`access.ReplaceOrganizationUserRoles` with a non-empty, unique `Roles` array +(maximum sixteen) and the same `ExpectedBindingIDs` convention. This operation +requires a current direct owner inside the write transaction for every change; +the older single-role API retains its delegated non-owner administration policy. +The replacement is all-or-nothing, leaves narrower grants untouched, and records +one audit. `ErrRoleChangeConflict` means refresh the displayed bindings, not retry +the old request silently. `RoleSetRepository` is required; separate grant/revoke +calls are not a fallback. A basic-member role with no permissions can represent +membership without purchasing or billing access. + +Role names and capabilities remain application policy. In particular, customer +roles must not be replaceable with merchant roles merely because both policies +use the same database. Routine customer changes do not inherently require a +passkey ceremony; the application decides when an action needs fresh proof. + +## Schema 10 compatibility + +Schema 10 adds `direct_roles_json` and `required_owner_role` to stored invitations. +The explicit migration preserves legacy `direct_role`, hashes, dates, teams, and +consumption state. It does not guess which application role historically meant +owner. Configure the correct `OwnerRole` when accepting pre-schema-10 owner +invitations; that service policy supplies their acceptance-time owner check. +New invitations carry the persisted requirement themselves. + +Use `OpenWithOptions(..., OpenOptions{Migrate: false})` plus +`RequireCurrentSchema` at application startup and an explicit operator migration +command. Retain a verified backup before migrating. Schema-9 binaries reject +schema 10 when using the startup check and are not approved writers after the +upgrade; a binary rollback must not overwrite newer accepted data. + `access.Service` evaluates a permission against a complete resource scope: ```go diff --git a/docs/PUBLIC_SNAPSHOT.md b/docs/PUBLIC_SNAPSHOT.md index 2a939b8..8a2606e 100644 --- a/docs/PUBLIC_SNAPSHOT.md +++ b/docs/PUBLIC_SNAPSHOT.md @@ -9,3 +9,8 @@ the exact same exported tree as a read-only discovery mirror. The exporter includes no branches, reflogs, private operational evidence, credentials, databases, logs, or development-only files. Public Gitea issues and pull requests are the contribution venue. + +`scripts/test-public-snapshot.sh` checks the exact allowlist and builds all +exported packages. New implementation and regression-test files must be included +explicitly; a successful build in the development checkout does not prove that +the smaller exported distribution is complete. diff --git a/organizations/organizations.go b/organizations/organizations.go index dc0c6d4..cc40dfa 100644 --- a/organizations/organizations.go +++ b/organizations/organizations.go @@ -14,6 +14,7 @@ import ( "fmt" "io" "regexp" + "slices" "strings" "time" ) @@ -77,11 +78,16 @@ type ApplicationService struct { } type Invitation struct { - ID string - Digest [32]byte - OrganizationID string - Email, InvitedByUserID string - DirectRole string + ID string + Digest [32]byte + OrganizationID string + Email, InvitedByUserID string + // DirectRole is the legacy single-role form. Use exactly one form. + DirectRole string + DirectRoles []string + // RequiredOwnerRole records the grantor authority to recheck at acceptance. + // Services set it from their trusted configuration, never a request payload. + RequiredOwnerRole string TeamIDs []string CreatedAt, ExpiresAt, UsedAt, RevokedAt time.Time } @@ -129,13 +135,17 @@ type Options struct { Random io.Reader Now func() time.Time OwnerRole string + // OwnerManagedInvitations requires a current direct owner to create, revoke, + // and remain the grantor of an invitation until it is accepted. + OwnerManagedInvitations bool } type Service struct { - repository Repository - random io.Reader - now func() time.Time - ownerRole string + repository Repository + random io.Reader + now func() time.Time + ownerRole string + ownerManagedInvitations bool } func New(repository Repository, options Options) (*Service, error) { @@ -148,10 +158,10 @@ func New(repository Repository, options Options) (*Service, error) { if options.Now == nil { options.Now = time.Now } - if options.OwnerRole != "" && !safeNamePattern.MatchString(options.OwnerRole) { + if options.OwnerRole != "" && !safeNamePattern.MatchString(options.OwnerRole) || options.OwnerManagedInvitations && options.OwnerRole == "" { return nil, errors.New("organizations: owner role is invalid") } - return &Service{repository: repository, random: options.Random, now: options.Now, ownerRole: options.OwnerRole}, nil + return &Service{repository: repository, random: options.Random, now: options.Now, ownerRole: options.OwnerRole, ownerManagedInvitations: options.OwnerManagedInvitations}, nil } type CreateOrganization struct { @@ -299,6 +309,8 @@ func (service *Service) Invite(ctx context.Context, organizationID, email, invit type InviteWithAccess struct { OrganizationID, Email, InvitedByUserID, DirectRole string + DirectRoles []string + RequestID string TeamIDs []string Lifetime time.Duration } @@ -307,9 +319,17 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc organizationID, email, invitedBy, lifetime := input.OrganizationID, input.Email, input.InvitedByUserID, input.Lifetime email = strings.ToLower(strings.TrimSpace(email)) input.DirectRole = strings.TrimSpace(input.DirectRole) - if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour || input.DirectRole != "" && !safeNamePattern.MatchString(input.DirectRole) || !validIDs(input.TeamIDs, 16) { + if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour || input.DirectRole != "" && !safeNamePattern.MatchString(input.DirectRole) || !validIDs(input.TeamIDs, 16) || !boundedOptional(input.RequestID, 128) { return "", Invitation{}, errors.New("organizations: invalid invitation") } + roles, err := (Invitation{DirectRole: input.DirectRole, DirectRoles: input.DirectRoles}).RoleNames() + if err != nil { + return "", Invitation{}, err + } + roleRepository, roleSupport := service.repository.(RoleInvitationRepository) + if (len(input.DirectRoles) > 0 || service.ownerManagedInvitations || service.ownerRole != "" && slices.Contains(roles, service.ownerRole)) && !roleSupport { + return "", Invitation{}, ErrRoleInvitationUnsupported + } id, err := token(service.random, 18) if err != nil { return "", Invitation{}, err @@ -320,11 +340,22 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc } now := service.now().UTC() invitation := Invitation{ID: id, Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, DirectRole: input.DirectRole, TeamIDs: append([]string(nil), input.TeamIDs...), CreatedAt: now, ExpiresAt: now.Add(lifetime)} - audit, err := service.audit(invitedBy, organizationID, "invitation.create", "invitation", id, "Organization invitation created") + if len(input.DirectRoles) > 0 { + invitation.DirectRoles = roles + } + if service.ownerManagedInvitations || service.ownerRole != "" && slices.Contains(roles, service.ownerRole) { + invitation.RequiredOwnerRole = service.ownerRole + } + audit, err := service.auditWithRequest(invitedBy, organizationID, "invitation.create", "invitation", id, input.RequestID, "Organization invitation created") if err != nil { return "", Invitation{}, err } - if err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit); err != nil { + if roleSupport { + err = roleRepository.CreateInvitationWithRoles(ctx, invitation, service.ownerRole, audit) + } else { + err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit) + } + if err != nil { return "", Invitation{}, err } return raw, invitation, nil @@ -344,6 +375,12 @@ func (service *Service) AcceptInvitation(ctx context.Context, rawToken, userID s if err != nil { return err } + if repository, ok := service.repository.(RoleInvitationRepository); ok { + return repository.AcceptInvitationWithRoles(ctx, digest, userID, service.ownerRole, now, audit) + } + if len(invitation.DirectRoles) > 0 || invitation.RequiredOwnerRole != "" || service.ownerManagedInvitations { + return ErrRoleInvitationUnsupported + } return service.repository.AcceptInvitation(ctx, digest, userID, now, audit) } diff --git a/organizations/role_invitations.go b/organizations/role_invitations.go new file mode 100644 index 0000000..7e86eaf --- /dev/null +++ b/organizations/role_invitations.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MPL-2.0 + +package organizations + +import ( + "context" + "errors" + "slices" + "time" +) + +var ErrRoleInvitationUnsupported = errors.New("organizations: atomic role-set invitations are unsupported") + +// RoleInvitationRepository must preserve the entire role set and its required +// owner authority, then commit acceptance, membership, grants and audit together. +type RoleInvitationRepository interface { + CreateInvitationWithRoles(context.Context, Invitation, string, AuditEvent) error + AcceptInvitationWithRoles(context.Context, [32]byte, string, string, time.Time, AuditEvent) error +} + +// RoleNames returns a validated copy of the invitation's direct roles. The older +// DirectRole remains supported; supplying both forms is an error, not a union. +func (invitation Invitation) RoleNames() ([]string, error) { + if invitation.DirectRole != "" && len(invitation.DirectRoles) > 0 || len(invitation.DirectRoles) > 16 { + return nil, errors.New("organizations: invalid invitation roles") + } + roles := append([]string(nil), invitation.DirectRoles...) + if invitation.DirectRole != "" { + roles = append(roles, invitation.DirectRole) + } + slices.Sort(roles) + for i, role := range roles { + if !safeNamePattern.MatchString(role) || i > 0 && role == roles[i-1] { + return nil, errors.New("organizations: invalid invitation role") + } + } + return roles, nil +} diff --git a/organizations/role_invitations_test.go b/organizations/role_invitations_test.go new file mode 100644 index 0000000..c5f9741 --- /dev/null +++ b/organizations/role_invitations_test.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MPL-2.0 + +package organizations + +import ( + "context" + "crypto/sha256" + "errors" + "slices" + "strings" + "testing" + "time" +) + +type roleInvitationRepositoryStub struct { + repositoryStub + audit AuditEvent + ownerRole string + created int +} + +func (r *roleInvitationRepositoryStub) CreateInvitationWithRoles(_ context.Context, invitation Invitation, ownerRole string, audit AuditEvent) error { + r.created++ + r.invitation, r.audit, r.ownerRole = invitation, audit, ownerRole + return nil +} + +func (r *roleInvitationRepositoryStub) AcceptInvitationWithRoles(_ context.Context, _ [32]byte, userID, ownerRole string, _ time.Time, audit AuditEvent) error { + r.acceptedUser, r.ownerRole, r.audit = userID, ownerRole, audit + return nil +} + +func TestInvitationRoleNamesAreBoundedAndUnambiguous(t *testing.T) { + input := []string{"buyer", "billing"} + roles, err := (Invitation{DirectRoles: input}).RoleNames() + if err != nil || !slices.Equal(roles, []string{"billing", "buyer"}) || !slices.Equal(input, []string{"buyer", "billing"}) { + t.Fatalf("roles=%v input=%v err=%v", roles, input, err) + } + for _, invitation := range []Invitation{ + {DirectRole: "owner", DirectRoles: []string{"buyer"}}, + {DirectRoles: []string{"buyer", "buyer"}}, + {DirectRoles: []string{"not a role"}}, + {DirectRoles: make([]string, 17)}, + } { + if _, err := invitation.RoleNames(); err == nil { + t.Fatalf("invalid roles accepted=%+v", invitation) + } + } + if roles, err = (Invitation{DirectRole: "buyer"}).RoleNames(); err != nil || !slices.Equal(roles, []string{"buyer"}) { + t.Fatalf("legacy=%v err=%v", roles, err) + } +} + +func TestRoleInvitationServicePreservesOwnerRequirementAndRequest(t *testing.T) { + r := &roleInvitationRepositoryStub{} + service, err := New(r, Options{OwnerRole: "owner", OwnerManagedInvitations: true}) + if err != nil { + t.Fatal(err) + } + input := InviteWithAccess{OrganizationID: "organization-123", InvitedByUserID: "owner-12345678", Email: " Member@example.test ", DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour, RequestID: "request-invite"} + raw, invitation, err := service.InviteWithAccess(t.Context(), input) + if err != nil { + t.Fatal(err) + } + if invitation.Digest != sha256.Sum256([]byte(raw)) || invitation.RequiredOwnerRole != "owner" || r.ownerRole != "owner" || r.audit.RequestID != input.RequestID || !slices.Equal(invitation.DirectRoles, []string{"billing", "buyer"}) || invitation.Email != "member@example.test" { + t.Fatalf("invitation or audit mismatch: %+v %+v", invitation, r.audit) + } + if !slices.Equal(input.DirectRoles, []string{"buyer", "billing"}) { + t.Fatal("caller roles mutated") + } + if err = service.AcceptInvitation(t.Context(), raw, "member-12345678"); err != nil || r.ownerRole != "owner" || r.acceptedUser != "member-12345678" { + t.Fatalf("accept=%v owner=%q user=%q", err, r.ownerRole, r.acceptedUser) + } + input.RequestID = strings.Repeat("x", 129) + if _, _, err = service.InviteWithAccess(t.Context(), input); err == nil || r.created != 1 { + t.Fatal("oversized request accepted") + } + if _, err = New(r, Options{OwnerManagedInvitations: true}); err == nil { + t.Fatal("owner-managed service without owner accepted") + } +} + +func TestRoleInvitationHasNoPartialLegacyFallback(t *testing.T) { + for _, input := range []InviteWithAccess{ + {DirectRoles: []string{"buyer", "billing"}}, {DirectRole: "owner"}, {}, + } { + r := &repositoryStub{} + service, err := New(r, Options{OwnerRole: "owner", OwnerManagedInvitations: true}) + if err != nil { + t.Fatal(err) + } + input.OrganizationID, input.InvitedByUserID, input.Email, input.Lifetime = "organization-123", "owner-12345678", "member@example.test", time.Hour + if _, _, err = service.InviteWithAccess(t.Context(), input); !errors.Is(err, ErrRoleInvitationUnsupported) || r.invitation.ID != "" { + t.Fatalf("legacy fallback=%v", err) + } + r.invitation = Invitation{OrganizationID: input.OrganizationID, ID: "invitation-1234", RequiredOwnerRole: "owner"} + if err = service.AcceptInvitation(t.Context(), strings.Repeat("a", 43), "member-12345678"); !errors.Is(err, ErrRoleInvitationUnsupported) || r.acceptedUser != "" { + t.Fatalf("legacy acceptance fallback=%v", err) + } + } +} diff --git a/scripts/public-snapshot.allow b/scripts/public-snapshot.allow index 9efd6de..2f29de5 100644 --- a/scripts/public-snapshot.allow +++ b/scripts/public-snapshot.allow @@ -5,6 +5,7 @@ .gitea/workflows/verify.yml .gitignore CHANGELOG.md +AI_DISCLOSURE.md CONTRIBUTING.md LICENSE LICENSES.md @@ -20,6 +21,8 @@ abuse/abuse_test.go account/account.go access/access.go access/access_test.go +access/role_sets.go +access/role_sets_test.go analytics/analytics.go analytics/analytics_test.go analytics/fuzz_test.go @@ -45,6 +48,9 @@ authsqlite/assisted_recovery.go authsqlite/bootstrap.go authsqlite/bootstrap_test.go authsqlite/organizations.go +authsqlite/owned_organization.go +authsqlite/owned_organization_test.go +authsqlite/role_sets_test.go authsqlite/passkey.go authsqlite/passkey_test.go authsqlite/recovery.go @@ -83,6 +89,10 @@ requestmeta/requestmeta.go requestmeta/requestmeta_test.go organizations/organizations.go organizations/organizations_test.go +organizations/owned.go +organizations/owned_test.go +organizations/role_invitations.go +organizations/role_invitations_test.go scripts/check-licenses.sh scripts/check-dependencies.sh scripts/check-vendored-webauthn.sh diff --git a/scripts/test-public-snapshot.sh b/scripts/test-public-snapshot.sh index 67ecb18..f40d740 100755 --- a/scripts/test-public-snapshot.sh +++ b/scripts/test-public-snapshot.sh @@ -15,6 +15,9 @@ while IFS= read -r path; do fi done < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) | LC_ALL=C sort >"$temporary/expected" diff -u "$temporary/expected" "$temporary/actual" +# The allowlist is a source distribution boundary: it must still contain the +# implementation files required by the exported packages, not just match itself. +(cd "$temporary/export" && GOWORK=off go build ./...) private_word='PRI''VATE' token_word='to''ken' private_pattern="BEGIN (RSA|OPENSSH|EC) ${private_word} KEY|Authorization: ${token_word}|/home/"'cole'"|/mnt/c/"'Users'"|"'eqlwiki'"-deploy|"'crspeelman'"@gmail\\.com"