Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f0b597943 | ||
|
|
59827bf641 | ||
|
|
fe6bd94c9a |
@@ -2,6 +2,51 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## v0.1.0-preview.18 — 2026-09-04
|
||||
|
||||
- Add owner-assisted account recovery for a documented human-review path when
|
||||
normal password, passkey, and recovery-code authentication is unavailable.
|
||||
Issuance requires an active direct organization owner and returns a bounded,
|
||||
single-use, 15-minute secret while persisting and auditing only its digest.
|
||||
- Invalidate the recovered member's existing password, passkeys, recovery
|
||||
codes, sessions, ceremonies, and older recovery grants when the reviewed
|
||||
enrollment is issued. Completion atomically installs one replacement
|
||||
password, passkey, and recovery-code set without issuing a normal session.
|
||||
- Keep identity and organization-visible recovery audits in the same SQLite
|
||||
transactions as their credential changes, and document the application
|
||||
boundary for fresh passkey authorization, secret-fragment delivery, and
|
||||
human evidence review.
|
||||
|
||||
## v0.1.0-preview.17 — 2026-09-04
|
||||
|
||||
- Add optimistic organization-membership suspension, reactivation, and
|
||||
removal for fresh-authentication administration flows. The exact displayed
|
||||
membership state is rechecked after acquiring the SQLite write lock, so a
|
||||
concurrent or stale ceremony fails without changing access or writing an
|
||||
audit event.
|
||||
- Keep membership lifecycle consequences transactional: suspension removes
|
||||
team membership, removal also revokes direct bindings, reactivation does not
|
||||
silently restore former teams, and every successful change appends its
|
||||
organization-visible audit before commit.
|
||||
- Strengthen last-owner protection to require another active direct owner
|
||||
whose platform account is also active. Existing storage adapters retain the
|
||||
legacy interface; security-sensitive applications fail closed unless their
|
||||
repository implements the optimistic lifecycle extension.
|
||||
|
||||
## v0.1.0-preview.16 — 2026-09-03
|
||||
|
||||
- Add bounded organization-member and direct user-role listings for
|
||||
application-owned access administration pages. Direct listings deliberately
|
||||
exclude team and narrower resource grants rather than flattening distinct
|
||||
authority into one apparent role.
|
||||
- Add atomic direct-role replacement with exact expected-binding checks,
|
||||
transactional access audit, active-member validation, and final active
|
||||
direct-owner protection. SQLite serializes competing replacements so stale
|
||||
administration fails with a stable conflict instead of partially applying.
|
||||
- Record the Gamertan administration dogfood boundary: applications authorize
|
||||
the route and fresh passkey assertion, while Foundations owns the reusable
|
||||
storage transaction and invariants.
|
||||
|
||||
## v0.1.0-preview.15 — 2026-09-03
|
||||
|
||||
- Permit applications to opt into an exact non-default HTTPS WebAuthn origin
|
||||
|
||||
@@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and
|
||||
deployment. Adopt one boundary at a time; Go compiles and links only the
|
||||
packages you import.
|
||||
|
||||
> **Public preview:** `v0.1.0-preview.15`. APIs may change before a stable
|
||||
> **Public preview:** `v0.1.0-preview.18`. APIs may change before a stable
|
||||
> release. Linux is the maintained release platform.
|
||||
|
||||
## Why Web Foundations?
|
||||
@@ -41,7 +41,7 @@ packages you import.
|
||||
| Atomic password-plus-passkey registration | [`account`](account) |
|
||||
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
|
||||
| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) |
|
||||
| Printable single-use recovery codes | [`authrecovery`](authrecovery) |
|
||||
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
|
||||
| Private SQLite persistence | [`authsqlite`](authsqlite) |
|
||||
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
|
||||
| Organizations, teams, and invitations | [`organizations`](organizations) |
|
||||
@@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy.
|
||||
Pin the preview in an application module:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web@v0.1.0-preview.15
|
||||
go get gamertan.com/web@v0.1.0-preview.18
|
||||
go mod verify
|
||||
```
|
||||
|
||||
An application may name the first package it intends to adopt:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.15
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.18
|
||||
```
|
||||
|
||||
The version belongs to the `gamertan.com/web` module. See the
|
||||
@@ -102,6 +102,9 @@ JSONL logging.
|
||||
request context without owning login routes or pages.
|
||||
- [`authwebauthn`](authwebauthn) provides discoverable passkey login,
|
||||
enrollment, operation-bound fresh approval, and bounded recovery.
|
||||
- [`authrecovery`](authrecovery) supports printable self-service recovery and
|
||||
a separate owner-assisted flow that atomically replaces compromised account
|
||||
credentials while writing both identity and organization-visible audits.
|
||||
- [`organizations`](organizations) and [`access`](access) keep platform
|
||||
operation separate from organization-data authority while supporting teams,
|
||||
invitations, scoped roles, and audited temporary access.
|
||||
|
||||
+87
-5
@@ -18,8 +18,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
|
||||
ErrLastOwner = errors.New("access: the last active direct owner must be preserved")
|
||||
ErrRoleChangeConflict = errors.New("access: role binding changed")
|
||||
ErrRoleUnchanged = errors.New("access: role is unchanged")
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
|
||||
)
|
||||
|
||||
type SubjectKind string
|
||||
@@ -116,6 +119,8 @@ type Repository interface {
|
||||
Grant(context.Context, Binding) error
|
||||
Revoke(context.Context, string, string, time.Time) error
|
||||
EffectiveBindings(context.Context, string, string) ([]Binding, error)
|
||||
OrganizationUserBindings(context.Context, string, int) ([]Binding, error)
|
||||
ReplaceOrganizationUserRole(context.Context, []string, Binding, string, AuditEvent) error
|
||||
CreateBreakGlass(context.Context, BreakGlass, AuditEvent) error
|
||||
ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error)
|
||||
AppendAccessAudit(context.Context, AuditEvent) error
|
||||
@@ -123,8 +128,9 @@ type Repository interface {
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
OwnerRole string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -132,6 +138,7 @@ type Service struct {
|
||||
policy Policy
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
ownerRole string
|
||||
}
|
||||
|
||||
func New(repository Repository, policy Policy, options Options) (*Service, error) {
|
||||
@@ -147,7 +154,12 @@ func New(repository Repository, policy Policy, options Options) (*Service, error
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now}, nil
|
||||
if options.OwnerRole != "" {
|
||||
if _, ok := policy.Roles[options.OwnerRole]; !ok {
|
||||
return nil, errors.New("access: owner role is unknown")
|
||||
}
|
||||
}
|
||||
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now, ownerRole: options.OwnerRole}, nil
|
||||
}
|
||||
|
||||
func (service *Service) Seed(ctx context.Context) error {
|
||||
@@ -183,6 +195,62 @@ func (service *Service) Grant(ctx context.Context, input Grant) (Binding, error)
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
// OrganizationUserBindings lists active, direct, organization-wide user role
|
||||
// bindings. Team and narrower project/environment/service grants remain
|
||||
// separate because an administration screen must not silently flatten their
|
||||
// authority into one apparent role.
|
||||
func (service *Service) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]Binding, error) {
|
||||
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 2000 {
|
||||
return nil, errors.New("access: invalid organization binding query")
|
||||
}
|
||||
return service.repository.OrganizationUserBindings(ctx, organizationID, limit)
|
||||
}
|
||||
|
||||
type OrganizationUserRoleChange struct {
|
||||
OrganizationID string
|
||||
UserID string
|
||||
Role string
|
||||
ActorUserID string
|
||||
RequestID string
|
||||
ExpectedBindingIDs []string
|
||||
}
|
||||
|
||||
// ReplaceOrganizationUserRole atomically replaces every current direct,
|
||||
// organization-wide role for one active member with exactly one role. The
|
||||
// expected binding IDs make concurrent administration fail closed. When an
|
||||
// owner role is configured, the repository also protects the final active
|
||||
// direct owner in the same transaction.
|
||||
func (service *Service) ReplaceOrganizationUserRole(ctx context.Context, input OrganizationUserRoleChange) (Binding, error) {
|
||||
if service.ownerRole == "" {
|
||||
return Binding{}, errors.New("access: owner role is required for role replacement")
|
||||
}
|
||||
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || !text(input.RequestID, 128, true) {
|
||||
return Binding{}, errors.New("access: invalid organization role replacement")
|
||||
}
|
||||
if _, ok := service.policy.Roles[input.Role]; !ok {
|
||||
return Binding{}, errors.New("access: unknown role")
|
||||
}
|
||||
expected, err := canonicalBindingIDs(input.ExpectedBindingIDs)
|
||||
if err != nil {
|
||||
return Binding{}, err
|
||||
}
|
||||
bindingID, err := randomID(service.random)
|
||||
if err != nil {
|
||||
return Binding{}, err
|
||||
}
|
||||
auditID, err := randomID(service.random)
|
||||
if err != nil {
|
||||
return Binding{}, err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
binding := Binding{ID: bindingID, SubjectKind: User, SubjectID: input.UserID, Role: input.Role, Scope: Scope{OrganizationID: input.OrganizationID}, GrantedBy: input.ActorUserID, GrantedAt: now}
|
||||
audit := AuditEvent{ID: auditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.role.replace", ResourceType: "user", ResourceID: input.UserID, RequestID: input.RequestID, Summary: "Direct organization role replaced", CreatedAt: now}
|
||||
if err = service.repository.ReplaceOrganizationUserRole(ctx, expected, binding, service.ownerRole, audit); err != nil {
|
||||
return Binding{}, err
|
||||
}
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
type Decision struct {
|
||||
Allowed bool
|
||||
Source string
|
||||
@@ -265,6 +333,20 @@ func randomID(random io.Reader) (string, error) {
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func canonicalBindingIDs(values []string) ([]string, error) {
|
||||
if len(values) > 16 {
|
||||
return nil, errors.New("access: invalid expected role bindings")
|
||||
}
|
||||
result := append([]string(nil), values...)
|
||||
sort.Strings(result)
|
||||
for index, value := range result {
|
||||
if !idPattern.MatchString(value) || index > 0 && result[index-1] == value {
|
||||
return nil, errors.New("access: invalid expected role bindings")
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func text(value string, limit int, emptyOK bool) bool {
|
||||
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
|
||||
}
|
||||
|
||||
+62
-2
@@ -4,6 +4,8 @@ package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -56,9 +58,57 @@ func TestScopeHierarchyAndLifetimeFailClosed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizationUserRoleReplacementIsBoundedAndCanonical(t *testing.T) {
|
||||
now := time.Unix(2000, 0).UTC()
|
||||
policy := Policy{Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"}, Permissions: map[string]string{"site.view": "View site"}, Grants: map[string][]string{"owner": {"site.view"}, "viewer": {"site.view"}}}
|
||||
if _, err := New(&repositoryStub{}, policy, Options{OwnerRole: "missing"}); err == nil {
|
||||
t.Fatal("unknown owner role accepted")
|
||||
}
|
||||
repository := &repositoryStub{}
|
||||
service, err := New(repository, policy, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding, err := service.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{
|
||||
OrganizationID: "org-12345678",
|
||||
UserID: "user-12345678",
|
||||
Role: "viewer",
|
||||
ActorUserID: "user-87654321",
|
||||
RequestID: "request-12345678",
|
||||
ExpectedBindingIDs: []string{"binding-22222222", "binding-11111111"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binding.Role != "viewer" || binding.SubjectKind != User || binding.Scope != (Scope{OrganizationID: "org-12345678"}) || binding.GrantedAt != now {
|
||||
t.Fatalf("binding=%+v", binding)
|
||||
}
|
||||
if !slices.Equal(repository.replacedExpected, []string{"binding-11111111", "binding-22222222"}) || repository.replacedOwnerRole != "owner" {
|
||||
t.Fatalf("expected=%v owner=%q", repository.replacedExpected, repository.replacedOwnerRole)
|
||||
}
|
||||
if repository.replacedAccessAudit.Action != "access.role.replace" || repository.replacedAccessAudit.ResourceID != "user-12345678" || repository.replacedAccessAudit.RequestID != "request-12345678" {
|
||||
t.Fatalf("audit=%+v", repository.replacedAccessAudit)
|
||||
}
|
||||
if _, err = service.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{OrganizationID: "org-12345678", UserID: "user-12345678", Role: "viewer", ActorUserID: "user-87654321", ExpectedBindingIDs: []string{"binding-11111111", "binding-11111111"}}); err == nil {
|
||||
t.Fatal("duplicate expected binding accepted")
|
||||
}
|
||||
serviceWithoutOwner, err := New(&repositoryStub{}, policy, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = serviceWithoutOwner.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{}); err == nil || errors.Is(err, ErrRoleChangeConflict) {
|
||||
t.Fatalf("missing owner role err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type repositoryStub struct {
|
||||
bindings []Binding
|
||||
breakGlass []BreakGlass
|
||||
bindings []Binding
|
||||
breakGlass []BreakGlass
|
||||
organizationUser []Binding
|
||||
replacedExpected []string
|
||||
replacedBinding Binding
|
||||
replacedOwnerRole string
|
||||
replacedAccessAudit AuditEvent
|
||||
}
|
||||
|
||||
func (*repositoryStub) SeedAccessPolicy(context.Context, Policy) error { return nil }
|
||||
@@ -67,6 +117,16 @@ func (*repositoryStub) Revoke(context.Context, string, string, time.Time) error
|
||||
func (repository *repositoryStub) EffectiveBindings(context.Context, string, string) ([]Binding, error) {
|
||||
return repository.bindings, nil
|
||||
}
|
||||
func (repository *repositoryStub) OrganizationUserBindings(context.Context, string, int) ([]Binding, error) {
|
||||
return repository.organizationUser, nil
|
||||
}
|
||||
func (repository *repositoryStub) ReplaceOrganizationUserRole(_ context.Context, expected []string, binding Binding, ownerRole string, audit AuditEvent) error {
|
||||
repository.replacedExpected = append([]string(nil), expected...)
|
||||
repository.replacedBinding = binding
|
||||
repository.replacedOwnerRole = ownerRole
|
||||
repository.replacedAccessAudit = audit
|
||||
return nil
|
||||
}
|
||||
func (repository *repositoryStub) CreateBreakGlass(_ context.Context, grant BreakGlass, _ AuditEvent) error {
|
||||
repository.breakGlass = []BreakGlass{grant}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authrecovery_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authrecovery"
|
||||
"gamertan.com/web/authsqlite"
|
||||
"gamertan.com/web/authwebauthn"
|
||||
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
|
||||
"gamertan.com/web/organizations"
|
||||
)
|
||||
|
||||
func TestOwnerAssistedRecoveryInvalidatesAndAtomicallyReplacesAccountCredentials(t *testing.T) {
|
||||
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
|
||||
store, err := authsqlite.Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
random := &counterReader{}
|
||||
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "home.owner", Email: "owner@example.test", DisplayName: "Home Owner", Password: "owner password for assisted recovery"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.member", Email: "member@example.test", DisplayName: "Recover Member", Password: "old member password before recovery"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
organizationsService, err := organizations.New(store, organizations.Options{Random: random, Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home, err := organizationsService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "assisted-home", Name: "Assisted Home", OwnerUserID: owner.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invitation, _, err := organizationsService.Invite(t.Context(), home.ID, target.Email, owner.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = organizationsService.AcceptInvitation(t.Context(), invitation, target.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy := access.Policy{
|
||||
Roles: map[string]string{"owner": "Organization owner", "viewer": "Organization viewer"},
|
||||
Permissions: map[string]string{"account.recover": "Recover an organization member"},
|
||||
Grants: map[string][]string{"owner": {"account.recover"}, "viewer": {}},
|
||||
}
|
||||
accessService, err := access.New(store, policy, access.Options{Random: random, 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)
|
||||
}
|
||||
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: target.ID, Role: "viewer", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
existingID := bytes.Repeat([]byte{7}, 32)
|
||||
existingJSON, err := json.Marshal(wa.Credential{ID: existingID, PublicKey: []byte{1, 2, 3}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: existingID, UserID: target.ID, Label: "Old passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "old-passkey-audit-id", ActorUserID: target.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Old passkey fixture", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
passkeys := &passkeyRecoveryStub{now: now, credentialID: bytes.Repeat([]byte{8}, 32)}
|
||||
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys, OwnerRole: "owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldCodes, err := recovery.ReplaceCodes(t.Context(), target.ID, target.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSession, _, err := authService.IssueSession(t.Context(), target.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, err = recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: target.ID, TargetUserID: owner.ID, RequestID: "request-denied-123", Reason: "Target asked for recovery after identity review"}); !errors.Is(err, authrecovery.ErrAssistedDenied) {
|
||||
t.Fatalf("non-owner assisted recovery err=%v", err)
|
||||
}
|
||||
if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); err != nil {
|
||||
t.Fatalf("denied recovery changed password: %v", err)
|
||||
}
|
||||
|
||||
loaded, grant, err := recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: owner.ID, TargetUserID: target.ID, RequestID: "request-assisted-123", Reason: "Member verified ownership through the documented support review"})
|
||||
if err != nil || loaded.ID != target.ID || grant == "" {
|
||||
t.Fatalf("loaded=%+v grant_present=%v err=%v", loaded, grant != "", err)
|
||||
}
|
||||
if _, err = authService.Session(t.Context(), oldSession); !errors.Is(err, auth.ErrSessionNotFound) {
|
||||
t.Fatalf("old session survived assisted recovery issue: %v", err)
|
||||
}
|
||||
if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("old password survived assisted recovery issue: %v", err)
|
||||
}
|
||||
credentials, err := store.CredentialsByUserID(t.Context(), target.ID)
|
||||
if err != nil || len(credentials) != 0 {
|
||||
t.Fatalf("old passkeys survived issue: credentials=%+v err=%v", credentials, err)
|
||||
}
|
||||
if _, _, err = recovery.Begin(t.Context(), target.Email, "old member password before recovery", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("old recovery path survived issue: %v", err)
|
||||
}
|
||||
|
||||
begin, err := recovery.BeginAssistedPasskey(t.Context(), grant, "Recovered passkey")
|
||||
if err != nil || begin.CeremonyToken == "" || passkeys.userID != target.ID || passkeys.beginBinding != grant {
|
||||
t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err)
|
||||
}
|
||||
result, err := recovery.FinishAssistedRecovery(t.Context(), grant, begin.CeremonyToken, "new member password after recovery", []byte(`{"fixture":true}`))
|
||||
if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
if _, err = authService.VerifyPassword(t.Context(), target.Email, "new member password after recovery"); err != nil {
|
||||
t.Fatalf("replacement password unavailable: %v", err)
|
||||
}
|
||||
credentials, err = store.CredentialsByUserID(t.Context(), target.ID)
|
||||
if err != nil || len(credentials) != 1 || !bytes.Equal(credentials[0].ID, passkeys.credentialID) {
|
||||
t.Fatalf("replacement credentials=%+v err=%v", credentials, err)
|
||||
}
|
||||
if _, err = recovery.BeginAssistedPasskey(t.Context(), grant, "Replay"); !errors.Is(err, authrecovery.ErrAssistedNotFound) {
|
||||
t.Fatalf("assisted grant replay err=%v", err)
|
||||
}
|
||||
if _, nextGrant, beginErr := recovery.Begin(t.Context(), target.Email, "new member password after recovery", result.RecoveryCodes[0]); beginErr != nil || nextGrant == "" {
|
||||
t.Fatalf("replacement recovery material unavailable: grant_present=%v err=%v", nextGrant != "", beginErr)
|
||||
}
|
||||
audits, err := accessService.Audit(t.Context(), home.ID, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seenIssue, seenComplete := false, false
|
||||
for _, audit := range audits {
|
||||
seenIssue = seenIssue || audit.Action == "access.account-recovery.issue" && audit.ActorUserID == owner.ID && audit.ResourceID == target.ID && audit.RequestID == "request-assisted-123"
|
||||
seenComplete = seenComplete || audit.Action == "access.account-recovery.complete" && audit.ActorUserID == target.ID && audit.ResourceID == target.ID
|
||||
}
|
||||
if !seenIssue || !seenComplete {
|
||||
t.Fatalf("organization recovery audits issue=%v complete=%v events=%+v", seenIssue, seenComplete, audits)
|
||||
}
|
||||
}
|
||||
+198
-14
@@ -16,6 +16,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authwebauthn"
|
||||
)
|
||||
@@ -25,6 +26,8 @@ const DefaultCodeCount = 10
|
||||
var (
|
||||
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
|
||||
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
|
||||
ErrAssistedNotFound = errors.New("authrecovery: assisted recovery grant not found")
|
||||
ErrAssistedDenied = errors.New("authrecovery: assisted recovery is not authorized")
|
||||
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
|
||||
)
|
||||
|
||||
@@ -49,6 +52,47 @@ type PasskeyRepository interface {
|
||||
CompletePasskeyRecovery(context.Context, PasskeyCompletion) error
|
||||
}
|
||||
|
||||
// AssistedGrant is the digest-only authority created by an organization
|
||||
// owner after a human recovery review. The plaintext token is returned once
|
||||
// to the caller and never persisted or audited.
|
||||
type AssistedGrant struct {
|
||||
Digest [32]byte
|
||||
OrganizationID, UserID string
|
||||
IssuedByUserID string
|
||||
CreatedAt, ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// AssistedIssue binds an owner-reviewed recovery to one organization member.
|
||||
// Reason is deliberately bounded and must not contain credential material.
|
||||
type AssistedIssue struct {
|
||||
OrganizationID, ActorUserID, TargetUserID, RequestID, Reason string
|
||||
}
|
||||
|
||||
// AssistedRepository provides the two transactional boundaries for delegated
|
||||
// recovery. Issuance invalidates all existing account authenticators and
|
||||
// sessions while recording both identity and organization-visible audits.
|
||||
// Completion consumes the grant exactly once and installs the replacement
|
||||
// password, passkey, and recovery-code set atomically.
|
||||
type AssistedRepository interface {
|
||||
Repository
|
||||
IssueAssistedRecovery(context.Context, AssistedGrant, string, auth.AuditEvent, access.AuditEvent) (auth.User, error)
|
||||
AssistedRecoveryGrant(context.Context, [32]byte, time.Time) (AssistedGrant, auth.User, error)
|
||||
CompleteAssistedRecovery(context.Context, AssistedCompletion) error
|
||||
}
|
||||
|
||||
// AssistedCompletion contains only the password hash, public passkey
|
||||
// credential, digest-only recovery codes, and secret-free audit material.
|
||||
type AssistedCompletion struct {
|
||||
GrantDigest [32]byte
|
||||
Credential authwebauthn.Credential
|
||||
PasswordHash string
|
||||
RecoveryDigests [][32]byte
|
||||
PasskeyAudit auth.AuditEvent
|
||||
RecoveryAudit auth.AuditEvent
|
||||
AccessAudit access.AuditEvent
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
// Passkeys performs recovery-bound WebAuthn registration ceremonies.
|
||||
type Passkeys interface {
|
||||
BeginRecoveryRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
|
||||
@@ -78,21 +122,25 @@ type PasswordVerifier interface {
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
CodeCount int
|
||||
GrantLifetime time.Duration
|
||||
Passkeys Passkeys
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
CodeCount int
|
||||
GrantLifetime time.Duration
|
||||
AssistedGrantLifetime time.Duration
|
||||
OwnerRole string
|
||||
Passkeys Passkeys
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
passwords PasswordVerifier
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
count int
|
||||
grantTTL time.Duration
|
||||
passkeys Passkeys
|
||||
repository Repository
|
||||
passwords PasswordVerifier
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
count int
|
||||
grantTTL time.Duration
|
||||
assistedTTL time.Duration
|
||||
ownerRole string
|
||||
passkeys Passkeys
|
||||
}
|
||||
|
||||
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
|
||||
@@ -111,10 +159,120 @@ func New(repository Repository, passwords PasswordVerifier, options Options) (*S
|
||||
if options.GrantLifetime == 0 {
|
||||
options.GrantLifetime = 10 * time.Minute
|
||||
}
|
||||
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute {
|
||||
if options.AssistedGrantLifetime == 0 {
|
||||
options.AssistedGrantLifetime = 15 * time.Minute
|
||||
}
|
||||
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute || options.AssistedGrantLifetime < 5*time.Minute || options.AssistedGrantLifetime > 30*time.Minute || options.OwnerRole != "" && !safeRole(options.OwnerRole) {
|
||||
return nil, errors.New("authrecovery: invalid recovery policy")
|
||||
}
|
||||
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, passkeys: options.Passkeys}, nil
|
||||
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, assistedTTL: options.AssistedGrantLifetime, ownerRole: options.OwnerRole, passkeys: options.Passkeys}, nil
|
||||
}
|
||||
|
||||
// IssueAssistedRecovery creates one owner-authorized, single-use recovery
|
||||
// token. The repository immediately invalidates the target's previous
|
||||
// password, passkeys, recovery codes, sessions, and pending ceremonies so the
|
||||
// reviewed recovery cannot race an older authenticator.
|
||||
func (service *Service) IssueAssistedRecovery(ctx context.Context, input AssistedIssue) (auth.User, string, error) {
|
||||
repository, ok := service.repository.(AssistedRepository)
|
||||
input.OrganizationID = strings.TrimSpace(input.OrganizationID)
|
||||
input.ActorUserID = strings.TrimSpace(input.ActorUserID)
|
||||
input.TargetUserID = strings.TrimSpace(input.TargetUserID)
|
||||
input.RequestID = strings.TrimSpace(input.RequestID)
|
||||
input.Reason = strings.TrimSpace(input.Reason)
|
||||
if !ok || service.passkeys == nil || service.ownerRole == "" {
|
||||
return auth.User{}, "", ErrPasskeyUnavailable
|
||||
}
|
||||
if !opaqueID(input.OrganizationID) || !opaqueID(input.ActorUserID) || !opaqueID(input.TargetUserID) || input.RequestID != "" && !opaqueID(input.RequestID) || len(input.Reason) < 8 || len(input.Reason) > 240 || strings.ContainsAny(input.Reason, "\x00\r\n") {
|
||||
return auth.User{}, "", errors.New("authrecovery: invalid assisted recovery request")
|
||||
}
|
||||
raw, err := token(service.random, 32)
|
||||
if err != nil {
|
||||
return auth.User{}, "", err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
grant := AssistedGrant{Digest: sha256.Sum256([]byte(raw)), OrganizationID: input.OrganizationID, UserID: input.TargetUserID, IssuedByUserID: input.ActorUserID, CreatedAt: now, ExpiresAt: now.Add(service.assistedTTL)}
|
||||
authAuditID, err := token(service.random, 18)
|
||||
if err != nil {
|
||||
return auth.User{}, "", err
|
||||
}
|
||||
accessAuditID, err := token(service.random, 18)
|
||||
if err != nil {
|
||||
return auth.User{}, "", err
|
||||
}
|
||||
summary := "Owner-assisted account recovery issued after human review. Reason: " + input.Reason
|
||||
authAudit := auth.AuditEvent{ID: authAuditID, ActorUserID: input.ActorUserID, Action: "auth.assisted-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
|
||||
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.account-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
|
||||
user, err := repository.IssueAssistedRecovery(ctx, grant, service.ownerRole, authAudit, accessAudit)
|
||||
if err != nil {
|
||||
return auth.User{}, "", err
|
||||
}
|
||||
return user, raw, nil
|
||||
}
|
||||
|
||||
// BeginAssistedPasskey starts a replacement ceremony without issuing a normal
|
||||
// session. The grant remains reusable for ceremony restart until completion or
|
||||
// expiry; only completion consumes it.
|
||||
func (service *Service) BeginAssistedPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) {
|
||||
repository, ok := service.repository.(AssistedRepository)
|
||||
if !ok || service.passkeys == nil {
|
||||
return authwebauthn.BeginResult{}, ErrPasskeyUnavailable
|
||||
}
|
||||
digest, err := grantDigest(rawGrant)
|
||||
if err != nil {
|
||||
return authwebauthn.BeginResult{}, ErrAssistedNotFound
|
||||
}
|
||||
_, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
|
||||
if err != nil {
|
||||
return authwebauthn.BeginResult{}, err
|
||||
}
|
||||
return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant))
|
||||
}
|
||||
|
||||
// FinishAssistedRecovery consumes a reviewed grant only inside the transaction
|
||||
// that installs every replacement credential and both audit trails. No normal
|
||||
// session is issued; the recovered user signs in with the new credentials.
|
||||
func (service *Service) FinishAssistedRecovery(ctx context.Context, rawGrant, ceremonyToken, password string, response []byte) (PasskeyFinishResult, error) {
|
||||
repository, ok := service.repository.(AssistedRepository)
|
||||
if !ok || service.passkeys == nil {
|
||||
return PasskeyFinishResult{}, ErrPasskeyUnavailable
|
||||
}
|
||||
digest, err := grantDigest(rawGrant)
|
||||
if err != nil {
|
||||
return PasskeyFinishResult{}, ErrAssistedNotFound
|
||||
}
|
||||
grant, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
|
||||
if err != nil {
|
||||
return PasskeyFinishResult{}, err
|
||||
}
|
||||
passwordHash, err := auth.HashPasswordWithRandom(password, service.random)
|
||||
if err != nil {
|
||||
return PasskeyFinishResult{}, err
|
||||
}
|
||||
codes, digests, err := GenerateCodeSet(service.random, service.count)
|
||||
if err != nil {
|
||||
return PasskeyFinishResult{}, err
|
||||
}
|
||||
credential, err := service.passkeys.FinishRecoveryRegistration(ctx, ceremonyToken, []byte(rawGrant), response, func(commitContext context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
|
||||
if verified.UserID != user.ID {
|
||||
return errors.New("authrecovery: assisted recovery identity mismatch")
|
||||
}
|
||||
completedAt := service.now().UTC()
|
||||
recoveryAuditID, auditErr := token(service.random, 18)
|
||||
if auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
accessAuditID, auditErr := token(service.random, 18)
|
||||
if auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
recoveryAudit := auth.AuditEvent{ID: recoveryAuditID, ActorUserID: user.ID, Action: "auth.assisted-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Owner-assisted recovery replaced the password, passkeys, recovery codes, and sessions.", CreatedAt: completedAt}
|
||||
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: grant.OrganizationID, ActorUserID: user.ID, Action: "access.account-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "The organization member completed owner-assisted account recovery.", CreatedAt: completedAt}
|
||||
return repository.CompleteAssistedRecovery(commitContext, AssistedCompletion{GrantDigest: digest, Credential: verified, PasswordHash: passwordHash, RecoveryDigests: digests, PasskeyAudit: passkeyAudit, RecoveryAudit: recoveryAudit, AccessAudit: accessAudit, CompletedAt: completedAt})
|
||||
})
|
||||
if err != nil {
|
||||
return PasskeyFinishResult{}, err
|
||||
}
|
||||
return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil
|
||||
}
|
||||
|
||||
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
|
||||
@@ -290,3 +448,29 @@ func token(random io.Reader, size int) (string, error) {
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func opaqueID(value string) bool {
|
||||
if len(value) < 8 || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character == '-' || character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeRole(value string) bool {
|
||||
if len(value) < 1 || len(value) > 96 {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character == '-' || character == '_' || character == '.' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/access"
|
||||
@@ -134,6 +135,159 @@ func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userI
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]access.Binding, error) {
|
||||
if !opaqueID(organizationID) || limit < 1 || limit > 2000 {
|
||||
return nil, errors.New("authsqlite: invalid organization binding query")
|
||||
}
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_id,b.role_name,b.granted_by_user_id,b.granted_at
|
||||
FROM gwf_access_bindings b
|
||||
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id
|
||||
WHERE b.organization_id=? AND b.subject_kind='user'
|
||||
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
|
||||
AND b.revoked_at IS NULL
|
||||
ORDER BY b.subject_id,b.role_name,b.id
|
||||
LIMIT ?`, organizationID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]access.Binding, 0)
|
||||
for rows.Next() {
|
||||
var binding access.Binding
|
||||
var granted int64
|
||||
if err = rows.Scan(&binding.ID, &binding.SubjectID, &binding.Role, &binding.GrantedBy, &granted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
binding.SubjectKind = access.User
|
||||
binding.Scope = access.Scope{OrganizationID: organizationID}
|
||||
binding.GrantedAt = time.Unix(granted, 0).UTC()
|
||||
result = append(result, binding)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) error {
|
||||
if !validOrganizationRoleReplacement(expected, replacement, ownerRole, audit) {
|
||||
return errors.New("authsqlite: invalid organization role replacement")
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Acquire the SQLite write lock before reading the optimistic binding set.
|
||||
// This serializes competing role replacements so the loser observes the
|
||||
// committed binding IDs and returns ErrRoleChangeConflict instead of an
|
||||
// ambiguous busy-snapshot error.
|
||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
|
||||
WHERE organization_id=? AND user_id=? AND status='active'
|
||||
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
|
||||
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active')`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return errors.New("authsqlite: role grantor is not active in organization")
|
||||
}
|
||||
|
||||
var active int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*)
|
||||
FROM gwf_organization_memberships m
|
||||
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
|
||||
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
|
||||
WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil {
|
||||
return err
|
||||
}
|
||||
if active != 1 {
|
||||
return errors.New("authsqlite: access subject is not active in organization")
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id,role_name FROM gwf_access_bindings
|
||||
WHERE organization_id=? AND subject_kind='user' AND subject_id=?
|
||||
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
|
||||
AND revoked_at IS NULL ORDER BY id`, replacement.Scope.OrganizationID, replacement.SubjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var currentIDs []string
|
||||
var currentRoles []string
|
||||
for rows.Next() {
|
||||
var id, role string
|
||||
if err = rows.Scan(&id, &role); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
currentIDs = append(currentIDs, id)
|
||||
currentRoles = append(currentRoles, role)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err = rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Equal(currentIDs, expected) {
|
||||
return access.ErrRoleChangeConflict
|
||||
}
|
||||
if len(currentRoles) == 1 && currentRoles[0] == replacement.Role {
|
||||
return access.ErrRoleUnchanged
|
||||
}
|
||||
if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) {
|
||||
var otherOwners int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
|
||||
FROM gwf_access_bindings b
|
||||
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
|
||||
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
|
||||
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
|
||||
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
|
||||
AND b.revoked_at IS NULL`, replacement.Scope.OrganizationID, replacement.SubjectID, ownerRole).Scan(&otherOwners); err != nil {
|
||||
return err
|
||||
}
|
||||
if otherOwners == 0 {
|
||||
return access.ErrLastOwner
|
||||
}
|
||||
}
|
||||
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=?
|
||||
WHERE organization_id=? AND subject_kind='user' AND subject_id=?
|
||||
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
|
||||
AND revoked_at IS NULL`, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Scope.OrganizationID, replacement.SubjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at)
|
||||
SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, replacement.ID, replacement.Scope.OrganizationID, replacement.SubjectID, replacement.Role, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return errors.New("authsqlite: replacement role has not been seeded")
|
||||
}
|
||||
if err = appendAccessAudit(ctx, tx, audit); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func validOrganizationRoleReplacement(expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) bool {
|
||||
if !safeName(ownerRole) || !opaqueID(replacement.ID) || replacement.SubjectKind != access.User || !opaqueID(replacement.SubjectID) || !safeName(replacement.Role) || replacement.Scope.Validate() != nil || replacement.Scope.ProjectID != "" || replacement.Scope.EnvironmentID != "" || replacement.Scope.ServiceID != "" || !opaqueID(replacement.GrantedBy) || replacement.GrantedAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
if !validAccessAudit(audit) || audit.OrganizationID != replacement.Scope.OrganizationID || audit.ActorUserID != replacement.GrantedBy || audit.Action != "access.role.replace" || audit.ResourceType != "user" || audit.ResourceID != replacement.SubjectID || !audit.CreatedAt.Equal(replacement.GrantedAt) {
|
||||
return false
|
||||
}
|
||||
if len(expected) > 16 || !slices.IsSorted(expected) {
|
||||
return false
|
||||
}
|
||||
for index, id := range expected {
|
||||
if !opaqueID(id) || index > 0 && expected[index-1] == id {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlass, audit access.AuditEvent) error {
|
||||
if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID {
|
||||
return errors.New("authsqlite: invalid break-glass event")
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authrecovery"
|
||||
)
|
||||
|
||||
func (store *Store) IssueAssistedRecovery(ctx context.Context, grant authrecovery.AssistedGrant, ownerRole string, authAudit auth.AuditEvent, accessAudit access.AuditEvent) (auth.User, error) {
|
||||
if !validAssistedGrant(grant) || !safeName(ownerRole) || !validAuditEvent(authAudit) || authAudit.ActorUserID != grant.IssuedByUserID || authAudit.Action != "auth.assisted-recovery.issue" || authAudit.ResourceType != "user" || authAudit.ResourceID != grant.UserID || !authAudit.CreatedAt.Equal(grant.CreatedAt) || !validAccessAudit(accessAudit) || accessAudit.OrganizationID != grant.OrganizationID || accessAudit.ActorUserID != grant.IssuedByUserID || accessAudit.Action != "access.account-recovery.issue" || accessAudit.ResourceType != "user" || accessAudit.ResourceID != grant.UserID || !accessAudit.CreatedAt.Equal(grant.CreatedAt) {
|
||||
return auth.User{}, errors.New("authsqlite: invalid assisted recovery issue")
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Take the SQLite write lock before checking owner authority so a role or
|
||||
// membership mutation cannot race the reviewed recovery decision.
|
||||
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' AND u.registration_pending=0)
|
||||
AND EXISTS (SELECT 1 FROM gwf_access_bindings b 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)`, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, ownerRole)
|
||||
if err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return auth.User{}, authrecovery.ErrAssistedDenied
|
||||
}
|
||||
|
||||
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at
|
||||
FROM gwf_users u JOIN gwf_organization_memberships m ON m.user_id=u.id
|
||||
WHERE u.id=? AND u.status='active' AND u.registration_pending=0 AND m.organization_id=? AND m.status='active'`, grant.UserID, grant.OrganizationID))
|
||||
if errors.Is(err, auth.ErrUserNotFound) {
|
||||
return auth.User{}, authrecovery.ErrAssistedDenied
|
||||
}
|
||||
if err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
|
||||
for _, statement := range []string{
|
||||
`DELETE FROM gwf_auth_sessions WHERE user_id=?`,
|
||||
`DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`,
|
||||
`DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`,
|
||||
`DELETE FROM gwf_recovery_grants WHERE user_id=?`,
|
||||
`DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`,
|
||||
`DELETE FROM gwf_password_credentials WHERE user_id=?`,
|
||||
`DELETE FROM gwf_passkey_credentials WHERE user_id=?`,
|
||||
`DELETE FROM gwf_recovery_codes WHERE user_id=?`,
|
||||
} {
|
||||
if _, err = tx.ExecContext(ctx, statement, grant.UserID); err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_assisted_recovery_grants(token_hash,user_id,organization_id,issued_by_user_id,created_at,expires_at) VALUES(?,?,?,?,?,?)`, grant.Digest[:], grant.UserID, grant.OrganizationID, grant.IssuedByUserID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
if err = appendAudit(ctx, tx, authAudit); err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
if err = appendAccessAudit(ctx, tx, accessAudit); err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (store *Store) AssistedRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (authrecovery.AssistedGrant, auth.User, error) {
|
||||
if zeroDigest(digest) || now.IsZero() {
|
||||
return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound
|
||||
}
|
||||
var grant authrecovery.AssistedGrant
|
||||
var user auth.User
|
||||
var created, expires, userCreated, userUpdated int64
|
||||
var passwordChangeRequired, registrationPending int
|
||||
err := store.db.QueryRowContext(ctx, `SELECT g.user_id,g.organization_id,g.issued_by_user_id,g.created_at,g.expires_at,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at
|
||||
FROM gwf_assisted_recovery_grants g
|
||||
JOIN gwf_users u ON u.id=g.user_id AND u.status='active' AND u.registration_pending=0
|
||||
JOIN gwf_organizations o ON o.id=g.organization_id AND o.status='active'
|
||||
JOIN gwf_organization_memberships m ON m.organization_id=g.organization_id AND m.user_id=g.user_id AND m.status='active'
|
||||
WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()).Scan(&grant.UserID, &grant.OrganizationID, &grant.IssuedByUserID, &created, &expires, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, ®istrationPending, &userCreated, &userUpdated)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return authrecovery.AssistedGrant{}, auth.User{}, err
|
||||
}
|
||||
grant.Digest, grant.CreatedAt, grant.ExpiresAt = digest, time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
|
||||
user.ID, user.PasswordChangeRequired, user.RegistrationPending = grant.UserID, passwordChangeRequired == 1, registrationPending == 1
|
||||
user.CreatedAt, user.UpdatedAt = time.Unix(userCreated, 0).UTC(), time.Unix(userUpdated, 0).UTC()
|
||||
return grant, user, nil
|
||||
}
|
||||
|
||||
func (store *Store) CompleteAssistedRecovery(ctx context.Context, completion authrecovery.AssistedCompletion) error {
|
||||
credential := completion.Credential
|
||||
credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID)
|
||||
if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || !text(completion.PasswordHash, 1024, false) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || !validAuditEvent(completion.RecoveryAudit) || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.assisted-recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID || !completion.RecoveryAudit.CreatedAt.Equal(completion.CompletedAt) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.ActorUserID != credential.UserID || completion.AccessAudit.Action != "access.account-recovery.complete" || completion.AccessAudit.ResourceType != "user" || completion.AccessAudit.ResourceID != credential.UserID || !completion.AccessAudit.CreatedAt.Equal(completion.CompletedAt) {
|
||||
return errors.New("authsqlite: invalid assisted recovery completion")
|
||||
}
|
||||
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
|
||||
for _, digest := range completion.RecoveryDigests {
|
||||
if zeroDigest(digest) {
|
||||
return errors.New("authsqlite: invalid assisted recovery-code digest")
|
||||
}
|
||||
if _, duplicate := seen[digest]; duplicate {
|
||||
return errors.New("authsqlite: duplicate assisted recovery-code digest")
|
||||
}
|
||||
seen[digest] = struct{}{}
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var userID, organizationID string
|
||||
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_assisted_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id,organization_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID, &organizationID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return authrecovery.ErrAssistedNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if userID != credential.UserID || organizationID != completion.AccessAudit.OrganizationID {
|
||||
return errors.New("authsqlite: assisted recovery identity mismatch")
|
||||
}
|
||||
var active int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users u
|
||||
JOIN gwf_organization_memberships m ON m.user_id=u.id AND m.organization_id=? AND m.status='active'
|
||||
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
|
||||
WHERE u.id=? AND u.status='active' AND u.registration_pending=0`, organizationID, userID).Scan(&active); err != nil {
|
||||
return err
|
||||
}
|
||||
if active != 1 {
|
||||
return auth.ErrInactiveUser
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`DELETE FROM gwf_auth_sessions WHERE user_id=?`,
|
||||
`DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`,
|
||||
`DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`,
|
||||
`DELETE FROM gwf_recovery_grants WHERE user_id=?`,
|
||||
`DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`,
|
||||
`DELETE FROM gwf_password_credentials WHERE user_id=?`,
|
||||
`DELETE FROM gwf_passkey_credentials WHERE user_id=?`,
|
||||
`DELETE FROM gwf_recovery_codes WHERE user_id=?`,
|
||||
} {
|
||||
if _, err = tx.ExecContext(ctx, statement, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, userID, completion.PasswordHash, completion.CompletedAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, userID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, digest := range completion.RecoveryDigests {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, digest[:], completion.CompletedAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, completion.CompletedAt.Unix(), userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = appendAudit(ctx, tx, completion.PasskeyAudit); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = appendAudit(ctx, tx, completion.RecoveryAudit); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func validAssistedGrant(grant authrecovery.AssistedGrant) bool {
|
||||
return !zeroDigest(grant.Digest) && opaqueID(grant.OrganizationID) && opaqueID(grant.UserID) && opaqueID(grant.IssuedByUserID) && !grant.CreatedAt.IsZero() && grant.ExpiresAt.After(grant.CreatedAt) && grant.ExpiresAt.Sub(grant.CreatedAt) >= 5*time.Minute && grant.ExpiresAt.Sub(grant.CreatedAt) <= 30*time.Minute
|
||||
}
|
||||
+165
-3
@@ -261,6 +261,34 @@ func (store *Store) MembershipsForUser(ctx context.Context, userID string) ([]or
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) OrganizationMemberships(ctx context.Context, organizationID string, limit int) ([]organizations.Membership, error) {
|
||||
if !opaqueID(organizationID) || limit < 1 || limit > 2000 {
|
||||
return nil, errors.New("authsqlite: invalid organization member query")
|
||||
}
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT m.user_id,m.status,m.joined_at
|
||||
FROM gwf_organization_memberships m
|
||||
JOIN gwf_organizations o ON o.id=m.organization_id
|
||||
WHERE m.organization_id=?
|
||||
ORDER BY m.joined_at,m.user_id
|
||||
LIMIT ?`, organizationID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]organizations.Membership, 0)
|
||||
for rows.Next() {
|
||||
var membership organizations.Membership
|
||||
var joined int64
|
||||
if err = rows.Scan(&membership.UserID, &membership.Status, &joined); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
membership.OrganizationID = organizationID
|
||||
membership.JoinedAt = time.Unix(joined, 0).UTC()
|
||||
result = append(result, membership)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID string) ([]organizations.Team, error) {
|
||||
if !opaqueID(organizationID) || !opaqueID(userID) {
|
||||
return nil, errors.New("authsqlite: invalid team query")
|
||||
@@ -425,6 +453,48 @@ func (store *Store) SetMembershipStatus(ctx context.Context, organizationID, use
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) error {
|
||||
if !validMembershipStatusChange(input, ownerRole, audit) {
|
||||
return organizations.ErrMembershipNotFound
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != input.ExpectedStatus {
|
||||
return organizations.ErrRevisionConflict
|
||||
}
|
||||
if input.Status == "suspended" {
|
||||
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=? WHERE organization_id=? AND user_id=? AND status=?`, input.Status, input.OrganizationID, input.UserID, input.ExpectedStatus)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return organizations.ErrRevisionConflict
|
||||
}
|
||||
if input.Status == "suspended" {
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, input.UserID, input.OrganizationID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID, ownerRole string, audit organizations.AuditEvent) error {
|
||||
if !opaqueID(organizationID) || !opaqueID(userID) || !safeName(ownerRole) || !validOrganizationAudit(audit, organizationID) {
|
||||
return organizations.ErrMembershipNotFound
|
||||
@@ -456,6 +526,77 @@ func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) error {
|
||||
if !validMembershipRemoval(input, ownerRole, audit) {
|
||||
return organizations.ErrMembershipNotFound
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != input.ExpectedStatus {
|
||||
return organizations.ErrRevisionConflict
|
||||
}
|
||||
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, input.UserID, input.OrganizationID); err != nil {
|
||||
return err
|
||||
}
|
||||
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 revoked_at IS NULL`, audit.ActorUserID, audit.CreatedAt.Unix(), input.OrganizationID, input.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status=?`, input.OrganizationID, input.UserID, input.ExpectedStatus)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return organizations.ErrRevisionConflict
|
||||
}
|
||||
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID, actorUserID string) error {
|
||||
// Acquire the SQLite write lock before reading the optimistic state. This
|
||||
// makes a competing lifecycle transaction observe the committed winner.
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return organizations.ErrMembershipNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func membershipStatus(ctx context.Context, tx *sql.Tx, organizationID, userID string) (string, error) {
|
||||
var status string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status FROM gwf_organization_memberships WHERE organization_id=? AND user_id=?`, organizationID, userID).Scan(&status); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", organizations.ErrMembershipNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if status != "active" && status != "suspended" {
|
||||
return "", errors.New("authsqlite: stored membership status is invalid")
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) error {
|
||||
var targetIsOwner int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_bindings WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND role_name=? AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL AND revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&targetIsOwner); err != nil {
|
||||
@@ -464,16 +605,37 @@ func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, o
|
||||
if targetIsOwner == 0 {
|
||||
return nil
|
||||
}
|
||||
var activeOwners 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' WHERE b.organization_id=? AND b.subject_kind='user' 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, ownerRole).Scan(&activeOwners); err != nil {
|
||||
var otherActiveOwners 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`, organizationID, userID, ownerRole).Scan(&otherActiveOwners); err != nil {
|
||||
return err
|
||||
}
|
||||
if activeOwners <= 1 {
|
||||
if otherActiveOwners == 0 {
|
||||
return organizations.ErrLastOwner
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validMembershipStatusChange(input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) bool {
|
||||
return opaqueID(input.OrganizationID) && opaqueID(input.UserID) && opaqueID(input.ActorUserID) && safeName(ownerRole) &&
|
||||
(input.ExpectedStatus == "active" || input.ExpectedStatus == "suspended") &&
|
||||
(input.Status == "active" || input.Status == "suspended") && input.ExpectedStatus != input.Status &&
|
||||
validOrganizationAudit(audit, input.OrganizationID) && audit.ActorUserID == input.ActorUserID &&
|
||||
audit.Action == "membership."+input.Status && audit.ResourceType == "membership" && audit.ResourceID == input.UserID && audit.RequestID == input.RequestID
|
||||
}
|
||||
|
||||
func validMembershipRemoval(input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) bool {
|
||||
return opaqueID(input.OrganizationID) && opaqueID(input.UserID) && opaqueID(input.ActorUserID) && safeName(ownerRole) &&
|
||||
(input.ExpectedStatus == "active" || input.ExpectedStatus == "suspended") &&
|
||||
validOrganizationAudit(audit, input.OrganizationID) && audit.ActorUserID == input.ActorUserID &&
|
||||
audit.Action == "membership.remove" && audit.ResourceType == "membership" && audit.ResourceID == input.UserID && audit.RequestID == input.RequestID
|
||||
}
|
||||
|
||||
func (store *Store) Invitations(ctx context.Context, organizationID string, limit int) ([]organizations.Invitation, error) {
|
||||
if !opaqueID(organizationID) || limit < 1 || limit > 1000 {
|
||||
return nil, errors.New("authsqlite: invalid invitation query")
|
||||
|
||||
+6
-1
@@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
|
||||
return store, nil
|
||||
}
|
||||
|
||||
const SchemaVersion = 8
|
||||
const SchemaVersion = 9
|
||||
|
||||
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
|
||||
var exists int
|
||||
@@ -136,6 +136,8 @@ func (store *Store) Migrate(ctx context.Context) error {
|
||||
`CREATE TABLE IF NOT EXISTS gwf_recovery_codes (user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, code_hash BLOB NOT NULL, created_at INTEGER NOT NULL, used_at INTEGER, PRIMARY KEY(user_id,code_hash))`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS gwf_recovery_grants_expiry ON gwf_recovery_grants(expires_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_assisted_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, issued_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS gwf_assisted_recovery_grants_expiry ON gwf_assisted_recovery_grants(expires_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_account_registrations (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS gwf_account_registrations_expiry ON gwf_account_registrations(expires_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
|
||||
@@ -234,6 +236,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(8,?)`, time.Now().UTC().Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
||||
+276
-3
@@ -438,7 +438,7 @@ func TestInvitationAccessLifecycleAndLastOwnerProtection(t *testing.T) {
|
||||
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "organization.owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", owner.ID, "request-last-owner"); !errors.Is(err, organizations.ErrLastOwner) {
|
||||
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: owner.ID, RequestID: "request-last-owner"}); !errors.Is(err, organizations.ErrLastOwner) {
|
||||
t.Fatalf("last-owner suspension err=%v", err)
|
||||
}
|
||||
team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators", ActorUserID: owner.ID})
|
||||
@@ -463,10 +463,10 @@ func TestInvitationAccessLifecycleAndLastOwnerProtection(t *testing.T) {
|
||||
if err != nil || len(teams) != 1 || teams[0].ID != team.ID {
|
||||
t.Fatalf("member teams=%+v err=%v", teams, err)
|
||||
}
|
||||
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", owner.ID, "request-suspend-owner"); err != nil {
|
||||
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: owner.ID, RequestID: "request-suspend-owner"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = organizationService.RemoveMembership(t.Context(), organization.ID, member.ID, member.ID, "request-last-member"); !errors.Is(err, organizations.ErrLastOwner) {
|
||||
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", ActorUserID: member.ID, RequestID: "request-last-member"}); !errors.Is(err, organizations.ErrLastOwner) {
|
||||
t.Fatalf("sole active owner removal err=%v", err)
|
||||
}
|
||||
if _, err = organizationService.SetOrganizationStatus(t.Context(), organizations.SetOrganizationStatus{ID: organization.ID, Status: "archived", ActorUserID: member.ID, ExpectedRevision: organization.Revision, RequestID: "request-archive"}); err != nil {
|
||||
@@ -477,3 +477,276 @@ func TestInvitationAccessLifecycleAndLastOwnerProtection(t *testing.T) {
|
||||
t.Fatalf("archived organization decision=%+v err=%v", decision, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) {
|
||||
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
now := time.Date(2026, 9, 3, 16, 0, 0, 0, time.UTC)
|
||||
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "access.owner", Email: "access-owner@example.test", DisplayName: "Access Owner", Password: "correct horse battery staple"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "access.member", Email: "access-member@example.test", DisplayName: "Access Member", Password: "correct horse battery staple"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "access-admin", Name: "Access Admin", OwnerUserID: owner.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _, err := organizationService.Invite(t.Context(), organization.ID, member.Email, owner.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy := access.Policy{
|
||||
Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"},
|
||||
Permissions: map[string]string{"site.view": "View site"},
|
||||
Grants: map[string][]string{"owner": {"site.view"}, "viewer": {"site.view"}},
|
||||
}
|
||||
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = accessService.Seed(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ownerBinding, err := accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memberBinding, err := accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "narrow", Name: "Narrow"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID, ProjectID: project.ID}, GrantedBy: owner.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
members, err := organizationService.Members(t.Context(), organization.ID, 10)
|
||||
if err != nil || len(members) != 2 || !membershipPresent(members, owner.ID, "active") || !membershipPresent(members, member.ID, "active") {
|
||||
t.Fatalf("members=%+v err=%v", members, err)
|
||||
}
|
||||
direct, err := accessService.OrganizationUserBindings(t.Context(), organization.ID, 10)
|
||||
if err != nil || len(direct) != 2 {
|
||||
t.Fatalf("direct=%+v err=%v", direct, err)
|
||||
}
|
||||
|
||||
type replacementResult struct {
|
||||
binding access.Binding
|
||||
err error
|
||||
}
|
||||
start := make(chan struct{})
|
||||
results := make(chan replacementResult, 2)
|
||||
for _, requestID := range []string{"request-member-owner-one", "request-member-owner-two"} {
|
||||
requestID := requestID
|
||||
go func() {
|
||||
<-start
|
||||
binding, replaceErr := accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: owner.ID, RequestID: requestID, ExpectedBindingIDs: []string{memberBinding.ID}})
|
||||
results <- replacementResult{binding: binding, err: replaceErr}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
var memberOwner access.Binding
|
||||
var successful, conflicted int
|
||||
for range 2 {
|
||||
result := <-results
|
||||
switch {
|
||||
case result.err == nil:
|
||||
successful++
|
||||
memberOwner = result.binding
|
||||
case errors.Is(result.err, access.ErrRoleChangeConflict):
|
||||
conflicted++
|
||||
default:
|
||||
t.Fatalf("concurrent replacement err=%v", result.err)
|
||||
}
|
||||
}
|
||||
if successful != 1 || conflicted != 1 {
|
||||
t.Fatalf("concurrent replacements success=%d conflict=%d", successful, conflicted)
|
||||
}
|
||||
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: owner.ID, RequestID: "request-stale", ExpectedBindingIDs: []string{memberBinding.ID}}); !errors.Is(err, access.ErrRoleChangeConflict) {
|
||||
t.Fatalf("stale replacement err=%v", err)
|
||||
}
|
||||
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: owner.ID, RequestID: "request-unchanged", ExpectedBindingIDs: []string{memberOwner.ID}}); !errors.Is(err, access.ErrRoleUnchanged) {
|
||||
t.Fatalf("unchanged replacement err=%v", err)
|
||||
}
|
||||
ownerViewer, err := accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-owner-viewer", ExpectedBindingIDs: []string{ownerBinding.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-last-owner", ExpectedBindingIDs: []string{memberOwner.ID}}); !errors.Is(err, access.ErrLastOwner) {
|
||||
t.Fatalf("last-owner demotion err=%v", err)
|
||||
}
|
||||
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "owner", ActorUserID: member.ID, RequestID: "request-restore-owner", ExpectedBindingIDs: []string{ownerViewer.ID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, member.ID, "suspended", owner.ID, "request-suspend"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
members, err = organizationService.Members(t.Context(), organization.ID, 10)
|
||||
if err != nil || len(members) != 2 || !membershipPresent(members, member.ID, "suspended") {
|
||||
t.Fatalf("suspended members=%+v err=%v", members, err)
|
||||
}
|
||||
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: owner.ID, RequestID: "request-suspended", ExpectedBindingIDs: []string{memberOwner.ID}}); err == nil {
|
||||
t.Fatal("suspended member role was replaced")
|
||||
}
|
||||
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, member.ID, "active", owner.ID, "request-reactivate"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
duplicateAudit := access.AuditEvent{ID: "audit-duplicate-1234", OrganizationID: organization.ID, ActorUserID: owner.ID, Action: "access.role.replace", ResourceType: "user", ResourceID: member.ID, RequestID: "request-rollback", Summary: "Direct organization role replaced", CreatedAt: now}
|
||||
if err = store.AppendAccessAudit(t.Context(), duplicateAudit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replacement := access.Binding{ID: "binding-rollback-1234", SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID, GrantedAt: now}
|
||||
if err = store.ReplaceOrganizationUserRole(t.Context(), []string{memberOwner.ID}, replacement, "owner", duplicateAudit); err == nil {
|
||||
t.Fatal("audit failure did not roll back role replacement")
|
||||
}
|
||||
direct, err = store.OrganizationUserBindings(t.Context(), organization.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var memberRoles []string
|
||||
for _, binding := range direct {
|
||||
if binding.SubjectID == member.ID {
|
||||
memberRoles = append(memberRoles, binding.ID+":"+binding.Role)
|
||||
}
|
||||
}
|
||||
if len(memberRoles) != 1 || memberRoles[0] != memberOwner.ID+":owner" {
|
||||
t.Fatalf("rollback member roles=%v", memberRoles)
|
||||
}
|
||||
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0)
|
||||
}
|
||||
|
||||
func TestOptimisticMembershipLifecycleIsSerializedAndAtomic(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, 4, 9, 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: "lifecycle.owner", Email: "lifecycle-owner@example.test", DisplayName: "Lifecycle Owner", Password: "correct horse battery staple"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "lifecycle.member", Email: "lifecycle-member@example.test", DisplayName: "Lifecycle 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: "optimistic-lifecycle", Name: "Optimistic Lifecycle", OwnerUserID: owner.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy := access.Policy{Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"}, Permissions: map[string]string{"telemetry.read": "Read"}, Grants: map[string][]string{"owner": {"telemetry.read"}, "viewer": {"telemetry.read"}}}
|
||||
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)
|
||||
}
|
||||
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators", ActorUserID: owner.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: member.Email, InvitedByUserID: owner.ID, DirectRole: "viewer", TeamIDs: []string{team.ID}, Lifetime: 24 * time.Hour})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
for _, requestID := range []string{"request-suspend-one", "request-suspend-two"} {
|
||||
requestID := requestID
|
||||
go func() {
|
||||
<-start
|
||||
results <- organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: owner.ID, RequestID: requestID})
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
var successful, conflicted int
|
||||
for range 2 {
|
||||
switch lifecycleErr := <-results; {
|
||||
case lifecycleErr == nil:
|
||||
successful++
|
||||
case errors.Is(lifecycleErr, organizations.ErrRevisionConflict):
|
||||
conflicted++
|
||||
default:
|
||||
t.Fatalf("concurrent membership suspension err=%v", lifecycleErr)
|
||||
}
|
||||
}
|
||||
if successful != 1 || conflicted != 1 {
|
||||
t.Fatalf("concurrent membership suspension success=%d conflict=%d", successful, conflicted)
|
||||
}
|
||||
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action='membership.suspended' AND resource_id=?`, member.ID, 1)
|
||||
assertCount(t, store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, member.ID, 0)
|
||||
decision, err := accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read")
|
||||
if err != nil || decision.Allowed {
|
||||
t.Fatalf("suspended member decision=%+v err=%v", decision, err)
|
||||
}
|
||||
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", ActorUserID: owner.ID, RequestID: "request-stale-remove"}); !errors.Is(err, organizations.ErrRevisionConflict) {
|
||||
t.Fatalf("stale membership removal err=%v", err)
|
||||
}
|
||||
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE request_id=?`, "request-stale-remove", 0)
|
||||
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, member.ID, 1)
|
||||
|
||||
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "suspended", Status: "active", ActorUserID: owner.ID, RequestID: "request-reactivate"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertCount(t, store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, member.ID, 0)
|
||||
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "suspended", Status: "active", ActorUserID: owner.ID, RequestID: "request-stale-reactivate"}); !errors.Is(err, organizations.ErrRevisionConflict) {
|
||||
t.Fatalf("stale membership reactivation err=%v", err)
|
||||
}
|
||||
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE request_id=?`, "request-stale-reactivate", 0)
|
||||
|
||||
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", ActorUserID: owner.ID, RequestID: "request-remove-member"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, member.ID, 0)
|
||||
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND revoked_at IS NOT NULL`, member.ID, 1)
|
||||
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE request_id=?`, "request-remove-member", 1)
|
||||
decision, err = accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read")
|
||||
if err != nil || decision.Allowed {
|
||||
t.Fatalf("removed member decision=%+v err=%v", decision, err)
|
||||
}
|
||||
}
|
||||
|
||||
func membershipPresent(values []organizations.Membership, userID, status string) bool {
|
||||
for _, value := range values {
|
||||
if value.UserID == userID && value.Status == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -54,3 +54,22 @@ application concern belongs in the shared module.
|
||||
service now permits an explicit development port only when applications opt
|
||||
in and the RP ID is `localhost` or reserved `.test`; production origins keep
|
||||
the original portless default.
|
||||
- Gamertan's staff-access page exposed a dangerous composition gap between
|
||||
individual grant/revoke calls. Foundations now owns one optimistic,
|
||||
transactional direct-role replacement that preserves the final active
|
||||
owner and appends its audit before commit. The application still owns route
|
||||
authorization, role presentation, CSRF, and the exact fresh-passkey
|
||||
operation binding.
|
||||
- Extending that page to membership suspension, reactivation, and removal
|
||||
exposed the same time-of-check gap in the older lifecycle methods. The new
|
||||
optimistic extension serializes on the active administrator membership,
|
||||
rechecks the exact state bound into the passkey assertion, applies team and
|
||||
direct-binding consequences, and writes the audit in one transaction.
|
||||
- Human-assisted recovery cannot safely be expressed as a root command behind
|
||||
an HTTP button. Preview 18 adds a distinct owner-assisted protocol: the
|
||||
application performs the human review and fresh operation-bound passkey
|
||||
ceremony, while the SQLite transaction rechecks an active direct owner,
|
||||
invalidates every old account authenticator, stores only the grant digest,
|
||||
and writes identity plus organization audits. Grant completion installs the
|
||||
replacement password, passkey, and recovery-code set atomically and never
|
||||
issues a session.
|
||||
|
||||
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
|
||||
module checksum:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.15
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.18
|
||||
go mod verify
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
|
||||
and request the containing module at an exact version:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.15
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.18
|
||||
```
|
||||
|
||||
Only imported packages are compiled and linked. The packages nevertheless
|
||||
|
||||
@@ -22,6 +22,27 @@ team membership can be removed independently. Configure `OwnerRole` when
|
||||
constructing the service before exposing membership-removal operations. The
|
||||
SQLite adapter then refuses to suspend or remove the final active direct owner.
|
||||
|
||||
Fresh-authentication administration pages should use
|
||||
`ChangeMembershipStatus` and `RemoveMembershipIfCurrent`, passing the exact
|
||||
displayed state as `ExpectedStatus`. The SQLite adapter acquires its write lock
|
||||
before checking that state, verifies the actor is still an active member of an
|
||||
active organization, and commits the lifecycle effects and audit together.
|
||||
Suspension removes team memberships; reactivation does not infer or restore
|
||||
them. Removal also revokes current direct bindings. A repository without the
|
||||
optimistic extension fails closed instead of falling back to a stale mutation.
|
||||
|
||||
For a reviewed access-administration page, use `organizations.Members` to list
|
||||
bounded active and suspended memberships, and
|
||||
`access.OrganizationUserBindings` to list only current direct,
|
||||
organization-wide user roles. The latter intentionally excludes team grants
|
||||
and project, environment, or service bindings. Replace a member's direct role
|
||||
with `access.ReplaceOrganizationUserRole`, passing the exact displayed binding
|
||||
IDs as `ExpectedBindingIDs`. The SQLite adapter serializes that replacement,
|
||||
rejects stale state, writes the new binding and audit event atomically, and
|
||||
will not demote the final active direct owner. The application must still
|
||||
authorize the administrator and bind any required fresh passkey assertion to
|
||||
the organization, target user, target role, and expected IDs.
|
||||
|
||||
`access.Service` evaluates a permission against a complete resource scope:
|
||||
|
||||
```go
|
||||
@@ -43,6 +64,15 @@ grant organization-data access. If an operator must inspect tenant data during
|
||||
an incident, use a reasoned break-glass grant. It expires within one hour and
|
||||
creates an append-only audit event in the same transaction.
|
||||
|
||||
An application that offers owner-assisted account recovery must not infer that
|
||||
authority from a broad administration page. Use the dedicated
|
||||
`authrecovery.IssueAssistedRecovery` boundary after an operation-bound passkey
|
||||
assertion. The SQLite adapter requires a current active direct owner binding
|
||||
and active target membership in the same transaction that invalidates the old
|
||||
credentials and records the organization-visible recovery audit. Team,
|
||||
break-glass, platform, and merely descriptive roles do not satisfy this owner
|
||||
check.
|
||||
|
||||
The SQLite adapter namespaces all tables, enforces active organization and team
|
||||
membership plus resource ancestry before accepting or evaluating a binding,
|
||||
and keeps invitations and sessions as digests. Applications remain responsible
|
||||
|
||||
@@ -85,5 +85,22 @@ ceremony until expiry. A binding mismatch consumes the mismatched ceremony.
|
||||
Applications must use generic failure responses and the same credential-attempt
|
||||
rate limiting as login.
|
||||
|
||||
Owner-assisted recovery is a third, deliberately separate path. Configure
|
||||
`authrecovery.Options.OwnerRole`, authorize an active direct organization owner,
|
||||
and bind that owner's fresh passkey assertion to the exact organization,
|
||||
target user, request identifier, and bounded human-review reason before calling
|
||||
`IssueAssistedRecovery`. The SQLite transaction rechecks the active direct
|
||||
owner and target membership, invalidates the target's password, passkeys,
|
||||
recovery codes, sessions, and pending ceremonies, then stores only a digest of
|
||||
the 15-minute grant with identity and organization-visible audits.
|
||||
|
||||
Deliver the returned grant exactly once in a URL fragment. A public recovery
|
||||
page can pass it to `BeginAssistedPasskey` and `FinishAssistedRecovery` while
|
||||
keeping it out of request URLs, referrers, and access logs. Completion consumes
|
||||
the grant atomically with one replacement password, passkey, recovery-code set,
|
||||
and both audit trails. It issues no session. Losing the fragment after issuance
|
||||
requires another reviewed owner or root-local recovery; old authenticators
|
||||
must not become valid again as a fallback.
|
||||
|
||||
Before enabling production mutations, applications should require at least two
|
||||
independent passkeys and complete a local recovery drill.
|
||||
|
||||
@@ -19,15 +19,16 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvitationNotFound = errors.New("organizations: invitation not found")
|
||||
ErrMembershipNotFound = errors.New("organizations: membership not found")
|
||||
ErrOrganizationNotFound = errors.New("organizations: organization not found")
|
||||
ErrTeamNotFound = errors.New("organizations: team not found")
|
||||
ErrRevisionConflict = errors.New("organizations: revision conflict")
|
||||
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
|
||||
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
|
||||
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||
ErrInvitationNotFound = errors.New("organizations: invitation not found")
|
||||
ErrMembershipNotFound = errors.New("organizations: membership not found")
|
||||
ErrMembershipLifecycleUnsupported = errors.New("organizations: optimistic membership lifecycle is unsupported")
|
||||
ErrOrganizationNotFound = errors.New("organizations: organization not found")
|
||||
ErrTeamNotFound = errors.New("organizations: team not found")
|
||||
ErrRevisionConflict = errors.New("organizations: revision conflict")
|
||||
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
|
||||
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
|
||||
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||
)
|
||||
|
||||
type Organization struct {
|
||||
@@ -107,10 +108,21 @@ type Repository interface {
|
||||
Invitations(context.Context, string, int) ([]Invitation, error)
|
||||
RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error
|
||||
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
|
||||
OrganizationMemberships(context.Context, string, int) ([]Membership, error)
|
||||
MembershipsForUser(context.Context, string) ([]Membership, error)
|
||||
TeamsForUser(context.Context, string, string) ([]Team, error)
|
||||
}
|
||||
|
||||
// OptimisticMembershipRepository is implemented by repositories that can
|
||||
// bind a membership lifecycle mutation to the exact state authorized by the
|
||||
// caller. Services deliberately do not fall back to the older lifecycle
|
||||
// methods: a stale fresh-authentication ceremony must fail instead of acting
|
||||
// on a membership that changed while the ceremony was in progress.
|
||||
type OptimisticMembershipRepository interface {
|
||||
ChangeMembershipStatus(context.Context, MembershipStatusChange, string, AuditEvent) error
|
||||
RemoveMembershipIfCurrent(context.Context, MembershipRemoval, string, AuditEvent) error
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
@@ -331,6 +343,16 @@ func (service *Service) Memberships(ctx context.Context, userID string) ([]Membe
|
||||
return service.repository.MembershipsForUser(ctx, userID)
|
||||
}
|
||||
|
||||
// Members returns a bounded, stable list of active and suspended memberships
|
||||
// for one organization. Authorization remains an application concern because
|
||||
// the same storage primitive serves different organization policies.
|
||||
func (service *Service) Members(ctx context.Context, organizationID string, limit int) ([]Membership, error) {
|
||||
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 2000 {
|
||||
return nil, errors.New("organizations: invalid member query")
|
||||
}
|
||||
return service.repository.OrganizationMemberships(ctx, organizationID, limit)
|
||||
}
|
||||
|
||||
func (service *Service) Teams(ctx context.Context, organizationID, userID string) ([]Team, error) {
|
||||
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) {
|
||||
return nil, errors.New("organizations: invalid team query")
|
||||
@@ -453,6 +475,34 @@ func (service *Service) SetMembershipStatus(ctx context.Context, organizationID,
|
||||
return service.repository.SetMembershipStatus(ctx, organizationID, userID, status, service.ownerRole, audit)
|
||||
}
|
||||
|
||||
// MembershipStatusChange describes an exact active-to-suspended or
|
||||
// suspended-to-active transition. ExpectedStatus is part of the authorized
|
||||
// operation and is checked again inside the repository transaction.
|
||||
type MembershipStatusChange struct {
|
||||
OrganizationID, UserID, ExpectedStatus, Status, ActorUserID, RequestID string
|
||||
}
|
||||
|
||||
func (service *Service) ChangeMembershipStatus(ctx context.Context, input MembershipStatusChange) error {
|
||||
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) ||
|
||||
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") ||
|
||||
(input.Status != "active" && input.Status != "suspended") || input.Status == input.ExpectedStatus ||
|
||||
!boundedOptional(input.RequestID, 128) {
|
||||
return errors.New("organizations: invalid membership status change")
|
||||
}
|
||||
if service.ownerRole == "" {
|
||||
return errors.New("organizations: owner role is required for membership lifecycle changes")
|
||||
}
|
||||
repository, ok := service.repository.(OptimisticMembershipRepository)
|
||||
if !ok {
|
||||
return ErrMembershipLifecycleUnsupported
|
||||
}
|
||||
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership."+input.Status, "membership", input.UserID, input.RequestID, "Organization membership set to "+input.Status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.ChangeMembershipStatus(ctx, input, service.ownerRole, audit)
|
||||
}
|
||||
|
||||
func (service *Service) RemoveMembership(ctx context.Context, organizationID, userID, actorUserID, requestID string) error {
|
||||
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) || !idPattern.MatchString(actorUserID) || !boundedOptional(requestID, 128) {
|
||||
return errors.New("organizations: invalid membership removal")
|
||||
@@ -467,6 +517,31 @@ func (service *Service) RemoveMembership(ctx context.Context, organizationID, us
|
||||
return service.repository.RemoveMembership(ctx, organizationID, userID, service.ownerRole, audit)
|
||||
}
|
||||
|
||||
// MembershipRemoval binds removal to the exact membership state observed by
|
||||
// the caller before fresh authentication began.
|
||||
type MembershipRemoval struct {
|
||||
OrganizationID, UserID, ExpectedStatus, ActorUserID, RequestID string
|
||||
}
|
||||
|
||||
func (service *Service) RemoveMembershipIfCurrent(ctx context.Context, input MembershipRemoval) error {
|
||||
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) ||
|
||||
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || !boundedOptional(input.RequestID, 128) {
|
||||
return errors.New("organizations: invalid membership removal")
|
||||
}
|
||||
if service.ownerRole == "" {
|
||||
return errors.New("organizations: owner role is required for membership lifecycle changes")
|
||||
}
|
||||
repository, ok := service.repository.(OptimisticMembershipRepository)
|
||||
if !ok {
|
||||
return ErrMembershipLifecycleUnsupported
|
||||
}
|
||||
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership.remove", "membership", input.UserID, input.RequestID, "Organization membership removed")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.RemoveMembershipIfCurrent(ctx, input, service.ownerRole, audit)
|
||||
}
|
||||
|
||||
func (service *Service) Invitations(ctx context.Context, organizationID string, limit int) ([]Invitation, error) {
|
||||
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 1000 {
|
||||
return nil, errors.New("organizations: invalid invitation query")
|
||||
|
||||
@@ -47,11 +47,25 @@ func TestInvitationFailsClosed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimisticMembershipLifecycleFailsClosedWithoutRepositorySupport(t *testing.T) {
|
||||
service, err := New(&repositoryStub{}, Options{OwnerRole: "organization.owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = service.ChangeMembershipStatus(t.Context(), MembershipStatusChange{OrganizationID: "organization-1234", UserID: "user-12345678", ExpectedStatus: "active", Status: "suspended", ActorUserID: "user-87654321", RequestID: "request-suspend"}); !errors.Is(err, ErrMembershipLifecycleUnsupported) {
|
||||
t.Fatalf("status change err=%v", err)
|
||||
}
|
||||
if err = service.RemoveMembershipIfCurrent(t.Context(), MembershipRemoval{OrganizationID: "organization-1234", UserID: "user-12345678", ExpectedStatus: "active", ActorUserID: "user-87654321", RequestID: "request-remove"}); !errors.Is(err, ErrMembershipLifecycleUnsupported) {
|
||||
t.Fatalf("removal err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type repositoryStub struct {
|
||||
organization Organization
|
||||
invitation Invitation
|
||||
invitationErr error
|
||||
acceptedUser string
|
||||
members []Membership
|
||||
}
|
||||
|
||||
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership, _ AuditEvent) error {
|
||||
@@ -109,4 +123,7 @@ func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte
|
||||
func (*repositoryStub) MembershipsForUser(context.Context, string) ([]Membership, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (repository *repositoryStub) OrganizationMemberships(context.Context, string, int) ([]Membership, error) {
|
||||
return repository.members, nil
|
||||
}
|
||||
func (*repositoryStub) TeamsForUser(context.Context, string, string) ([]Team, error) { return nil, nil }
|
||||
|
||||
@@ -30,6 +30,7 @@ auth/password.go
|
||||
auth/password_test.go
|
||||
authrecovery/recovery.go
|
||||
authrecovery/recovery_test.go
|
||||
authrecovery/assisted_test.go
|
||||
auth/service_test.go
|
||||
authhttp/authhttp.go
|
||||
authhttp/authhttp_test.go
|
||||
@@ -40,6 +41,7 @@ authsqlite/store_test.go
|
||||
authsqlite/account.go
|
||||
authsqlite/account_test.go
|
||||
authsqlite/access.go
|
||||
authsqlite/assisted_recovery.go
|
||||
authsqlite/bootstrap.go
|
||||
authsqlite/bootstrap_test.go
|
||||
authsqlite/organizations.go
|
||||
|
||||
Reference in New Issue
Block a user