Compare commits

...
23 Commits
Author SHA1 Message Date
gamertan 262e7196fe Document published account-mail preview and delivery limits
verify / verify (push) Successful in 4m32s
2026-09-11 08:34:46 -04:00
gamertan 494b72fa3b Release v0.1.0-preview.28: transactional account email
verify / verify (push) Successful in 4m29s
Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
2026-09-11 04:12:17 -04:00
gamertan ebcbbf06f5 Document and verify optional CMS associations
verify / verify (push) Successful in 4m50s
2026-09-10 18:00:25 -04:00
gamertan fcb200453a Close the shared CMS package delivery checkpoint
verify / verify (push) Successful in 4m41s
2026-09-10 12:44:31 -04:00
gamertan d991ad4bdb Record CMS package verification for preview 27
verify / verify (push) Successful in 4m39s
2026-09-10 12:39:30 -04:00
gamertan 57d74bf601 Add revision-aware CMS taxonomies and relationships 2026-09-10 12:37:21 -04:00
gamertan a16283efd7 Add session-bound personal profile editing
verify / verify (push) Successful in 4m35s
2026-09-05 02:37:38 -04:00
gamertan 7c68a3499a Add bounded administrative identity directories
verify / verify (push) Successful in 4m31s
2026-09-05 01:19:57 -04:00
gamertan d476179148 Guard customer-owned profile and membership transactions
verify / verify (push) Successful in 4m25s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-09-05 00:33:24 -04:00
gamertan f142ac23a9 Invalidate old invitations when organization membership changes
verify / verify (push) Successful in 4m15s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-09-05 00:12:40 -04:00
gamertan c0986168bc Support atomic organization role sets and owner-managed invitations
verify / verify (push) Successful in 4m17s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-09-05 00:10:08 -04:00
gamertan ed0cc8ceff Add atomic owned organization creation
verify / verify (push) Successful in 3m48s
2026-09-04 23:19:05 -04:00
gamertan b1710e08b8 Verify passkey algorithms from COSE keys
verify / verify (push) Successful in 3m38s
2026-09-04 12:19:15 -04:00
gamertan 3fe1547a5b Protect owner invitation authority
verify / verify (push) Successful in 3m40s
2026-09-04 00:30:29 -04:00
gamertan d54d6a4ad1 Protect owner administration authority
verify / verify (push) Successful in 3m42s
2026-09-04 00:20:28 -04:00
gamertan 6f0b597943 Add owner-assisted account recovery
verify / verify (push) Successful in 3m39s
2026-09-03 23:56:42 -04:00
gamertan 59827bf641 Add optimistic membership lifecycle
verify / verify (push) Successful in 3m38s
2026-09-03 23:22:19 -04:00
gamertan fe6bd94c9a Add atomic organization role administration
verify / verify (push) Successful in 3m39s
2026-09-03 22:51:53 -04:00
gamertan 17bd9453e2 Allow explicit local WebAuthn ports
verify / verify (push) Successful in 3m35s
2026-09-03 22:30:23 -04:00
gamertan d8b09c8ae5 Reject malformed PDF media uploads
verify / verify (push) Successful in 3m38s
2026-09-03 13:47:56 -04:00
gamertan 95d50f0888 Complete passkey recovery transaction
verify / verify (push) Successful in 3m37s
2026-09-03 13:09:22 -04:00
gamertan 1f54c75501 Add atomic initial owner bootstrap
verify / verify (push) Successful in 3m33s
2026-09-03 12:50:16 -04:00
gamertan 277cffed8c Bind passkey enrollment to authenticated user
verify / verify (push) Successful in 3m33s
2026-09-03 12:40:20 -04:00
76 changed files with 9973 additions and 131 deletions
+10
View File
@@ -0,0 +1,10 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# AI assistance
Codex has materially assisted with implementation, tests, documentation, and
integration work in this project, including the organization and access-control
extensions. Assistance is disclosed here rather than repeated in every commit
subject. Repository tests, review, and release evidence—not the use of a
particular tool—determine readiness. Automated checks do not imply that every
line has received independent human review.
+233
View File
@@ -2,6 +2,239 @@
# Changelog
## v0.1.0-preview.28 — 2026-09-11
- Add bounded single-recipient SMTP with verified TLS, a fixed sender and
secret-free diagnostics, plus an explicit encrypted SQLite transactional outbox.
Cover cancellation, retry/lease recovery, idempotency and payload expiry locally.
- Add optional verified-mailbox, both-mailbox address-change and password-reset
protocols with a separate explicit SQLite migration, atomic audits/mail intent,
session/credential/replay checks and preserved passkeys, ownership and history.
Consumer HTTP/UI integration is locally verified; deployment and real delivery
proof remain separate consumer checkpoints.
- Document and test optional CMS classification and relationships, including
unclassified publication and clearing associations with preserved history.
No runtime behavior or schema change.
- Keep the existing passkey algorithm regression tests in the public export.
## v0.1.0-preview.27 — 2026-09-10
- Add independent `cms` values and a `cmssqlite` adapter for named taxonomies,
stable terms, immutable revision associations and bidirectional editorial
links. Applications retain typed content, templates, permissions and commerce.
- Join caller-owned SQLite transactions so content, publication pointers and
application audits commit together. Schema installation is explicit and
independent of authentication schema 11, which remains unchanged.
- Preserve term URL aliases and historical associations on rename/retirement.
Query only published snapshots, with namespace isolation and bounded keyset
pagination. The application still checks each target's current availability.
- Include executable integration guidance and regressions for conflicts,
rollback, draft isolation, reverse discovery, aliases and retirement. The
consumer exercised native editing and publication behind trusted local TLS.
## v0.1.0-preview.26 — 2026-09-05
- Add optional self-profile readers and revision-checked username/display-name
writes. Recheck the active session, account and expected revision atomically
with a secret-free audit; preserve immutable user identity and ownership.
- Username edits revoke other sessions but preserve the acting session. A
password-confirmed write can require the exact verified credential hash,
rejecting a concurrent password reset. Applications own reauthentication,
operation-bound passkey approval, CSRF/origin checks and rate/concurrency limits.
- Add explicit SQLite schema 11 for monotonic profile revisions. Existing rows
begin at revision 1; startup with migrations disabled rejects older schemas.
Do not run older writers against schema 11 as a database rollback strategy.
- Email changes are deliberately absent; pending-address verification and mail
delivery are separate work. Test invalid/restricted sessions, collisions,
concurrent/stale edits, audit rollback, restart and schema-10 migration.
## v0.1.0-preview.25 — 2026-09-05
- Add optional, credential-free user and organization directory readers for
application-authorized instance administration. They include inactive/pending
users and personal/archived organizations independently of membership.
- Bound literal searches and stable-ID pagination to at most 200 returned
records. Queries do not load passwords, sessions, recovery material, invitations
or role grants; they grant no authority. Applications must authorize each read.
- Cover pagination, renamed records, literal SQL/wildcard input, Unicode text,
invalid bounds and cancellation. Schema 10 and existing repository contracts
remain unchanged; source exports include the new optional interfaces/readers.
## v0.1.0-preview.24 — 2026-09-05
- Add explicit owner-managed profile and optimistic membership operations.
Current direct ownership is checked inside the SQLite write transaction even
when the target is an ordinary member. Active account/membership, non-personal
organization, last-owner, optimistic state, and atomic audit requirements remain
intact. Existing delegated-administrator APIs retain their behavior.
- Require `OwnerManagedRepository` support without a preflight-only fallback.
No schema migration is added; schema 10 remains current.
- Test revoked, narrowed, suspended, removed and incomplete actor authority,
archived/personal organizations, stale and concurrent submissions, and rollback
of profile, membership, team, role and invitation effects after audit failure.
## v0.1.0-preview.23 — 2026-09-05
- Add atomic direct organization role sets with optimistic binding IDs, current
direct-owner authorization, last-owner protection, and a single audit. Roles
may be combined without changing narrower or team grants.
- Add bounded multiple-role invitations and opt-in owner-managed invitation
policy. Persist the required grantor authority and recheck it at acceptance,
together with active, fully registered users and recipient email. Suspended
members cannot reactivate themselves by accepting an older invitation.
- Invitations enroll new members rather than adding permissions to existing
members. Membership removal revokes pending invitations for that recipient
in the same transaction, preventing an older offer from restoring access.
- Add SQLite schema 10 for invitation role sets and stored owner authority.
Legacy single-role data remains readable after explicit migration; older
schema-9 applications are not approved writers of the migrated database.
Custom repositories must implement the role-set extensions before exposing
these operations; there is no non-atomic fallback.
- Include the owned-organization implementation and tests in the public-source
export, and compile the exported tree to catch incomplete source distributions.
- Cover competing changes and invitation acceptance, failure rollback, stale
owners, unsupported adapters, and migration of legacy invitations.
## v0.1.0-preview.22 — 2026-09-04
- Add `organizations.CreateOwnedOrganization` for atomic creation of an existing
user's organization, initial membership, direct configured owner role, and
correlated organization/access audits.
- Require an active, fully registered owner and a pre-seeded role inside the
SQLite transaction. Missing storage support fails without a non-atomic fallback.
- Preserve the older membership-only creation API and schema version 9. Customer
and merchant permissions remain application-owned, with no commerce dependency.
- Exercise failure at every write stage, concurrent duplicate creation, scoped
access, restart recovery, last-owner protection, and mismatched authority/audits.
## v0.1.0-preview.21 — 2026-09-04
- Derive the registered credential algorithm from the verified COSE public key
embedded in authenticator data instead of the optional browser
`publicKeyAlgorithm` convenience member.
- Preserve the ES256-only policy while accepting standards-compliant response
serializers that omit redundant response conveniences, including the
Bitwarden/Vaultwarden passkey flow exercised through Gamertan.
- Add regression coverage for an ES256 credential whose convenience algorithm
is absent, plus malformed and non-ES256 credential rejection.
## v0.1.0-preview.20 — 2026-09-04
- Extend the direct-owner transaction boundary to invitations. Creating or
revoking an invitation that grants the configured owner role now requires
the actor to remain an active direct owner after the SQLite write lock is
acquired.
- Preserve application-owned permission policy for ordinary invitations while
preventing a broad access-management role, stale ceremony, or alternate
repository call from creating or cancelling owner access.
- Pass the configured owner role explicitly through invitation repository
mutations so non-SQLite adapters cannot silently omit the invariant.
## v0.1.0-preview.19 — 2026-09-04
- Require a current active direct owner for every direct-role transition to or
from the configured owner role. The SQLite adapter rechecks that authority
after acquiring its write lock, preventing a role manager from promoting
itself or changing an owner through a stale application authorization.
- Apply the same transactional owner-authority boundary to membership
suspension, reactivation, and removal, including the legacy lifecycle
methods. Non-owner administrators may still manage non-owner members while
last-owner protection remains a separate invariant.
- Expose stable owner-authority errors so applications can distinguish an
authorization drift conflict from malformed input or storage failure.
## 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
port for `localhost` and reserved `.test` relying-party IDs. The configured
origin remains exact, production origins remain portless by default, and
malformed, default, non-canonical, zero, or out-of-range ports fail closed.
- Record the Gamertan local-Caddy dogfood pressure that required this explicit
development boundary without weakening cross-origin ceremony rejection.
## v0.1.0-preview.14 — 2026-09-03
- Reject header-only, truncated, and structurally invalid PDF uploads in the
bounded media preparer. Accepted attachments now require a supported PDF
version, terminal EOF marker, numeric in-range `startxref`, and either a
traditional xref/trailer or xref-stream object at the declared offset.
- Keep PDF handling storage-neutral and non-rendering: applications still own
authorization, reference tracking, attachment disposition, and lifecycle.
## v0.1.0-preview.13 — 2026-09-03
- Complete the password-plus-recovery-code flow with a short-lived restricted
grant bound into a replacement-passkey ceremony. Completion atomically
consumes the grant, stores the verified passkey, replaces every recovery
code, revokes any intervening sessions and ceremonies, and records both
audits without issuing a normal session.
- Keep failed completion retryable until grant expiry: a duplicate credential
or other transaction failure rolls back grant consumption and recovery-code
replacement, while a mismatched WebAuthn binding consumes only the affected
ceremony.
## v0.1.0-preview.12 — 2026-09-03
- Add a root-local bootstrap transaction that creates the first passkey-only
application owner, non-personal organization, active membership, direct
owner binding, one-time enrollment digest, and secret-free audit records
atomically.
- Fail closed and roll back the entire bootstrap when the application has not
seeded the configured owner role. The raw enrollment token is returned only
after commit and never enters repository state or audit records.
## v0.1.0-preview.11 — 2026-09-03
- Add expected-user completion for authenticated self-service passkey
enrollment. A mismatched ceremony is consumed and fails before credential
persistence, closing an authorization seam found while dogfooding Gamertan's
account security page.
## v0.1.0-preview.10 — 2026-09-03
- Add atomic public-account registration with required canonical email,
+11 -4
View File
@@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and
deployment. Adopt one boundary at a time; Go compiles and links only the
packages you import.
> **Public preview:** `v0.1.0-preview.10`. APIs may change before a stable
> **Public preview:** `v0.1.0-preview.28`. APIs may change before a stable
> release. Linux is the maintained release platform.
## Why Web Foundations?
@@ -40,9 +40,13 @@ packages you import.
| Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) |
| Atomic password-plus-passkey registration | [`account`](account) |
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
| Printable single-use recovery codes | [`authrecovery`](authrecovery) |
| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) |
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
| Optional mailbox verification, address changes and password reset | [`authmail`](authmail) + [`authsqlite`](authsqlite); explicit mail migration |
| Bounded transactional SMTP and encrypted queued delivery | [`mail`](mail) + [`mailsqlite`](mailsqlite) |
| Private SQLite persistence | [`authsqlite`](authsqlite) |
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
| Typed editorial categories and related-content discovery | [`cms`](cms) + [`cmssqlite`](cmssqlite); [integration guide](docs/CMS.md) |
| Organizations, teams, and invitations | [`organizations`](organizations) |
| Organization-scoped roles and temporary access | [`access`](access) |
| Application-classified request abuse | [`abuse`](abuse) |
@@ -56,14 +60,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.10
go get gamertan.com/web@v0.1.0-preview.28
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.10
go get gamertan.com/web/requestmeta@v0.1.0-preview.28
```
The version belongs to the `gamertan.com/web` module. See the
@@ -101,6 +105,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.
+89 -5
View File
@@ -18,8 +18,12 @@ 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")
ErrOwnerAuthority = errors.New("access: a current direct owner must approve owner role changes")
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 +120,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 +129,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 +139,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 +155,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 +196,63 @@ 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 requires a current active
// direct owner for any change to or from that role and 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 +335,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
View File
@@ -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
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MPL-2.0
package access
import (
"context"
"errors"
"sort"
)
var ErrRoleSetUnsupported = errors.New("access: atomic role sets are unsupported")
// RoleSetRepository commits every replacement and the audit atomically. There
// is no sequence of individual Grant/Revoke calls as a fallback.
type RoleSetRepository interface {
ReplaceOrganizationUserRoles(context.Context, []string, []Binding, string, AuditEvent) error
}
type OrganizationUserRolesChange struct {
OrganizationID, UserID, ActorUserID, RequestID string
Roles, ExpectedBindingIDs []string
}
// ReplaceOrganizationUserRoles replaces the direct organization-wide role set
// for one active member. Team and narrower grants are unaffected. This bulk
// operation requires a current direct owner inside the write transaction;
// applications still authorize their customer/merchant and allowed-role boundary.
func (service *Service) ReplaceOrganizationUserRoles(ctx context.Context, input OrganizationUserRolesChange) ([]Binding, error) {
repository, ok := service.repository.(RoleSetRepository)
if !ok {
return nil, ErrRoleSetUnsupported
}
if service.ownerRole == "" || !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || !text(input.RequestID, 128, true) || len(input.Roles) < 1 || len(input.Roles) > 16 {
return nil, errors.New("access: invalid organization role set")
}
roles := append([]string(nil), input.Roles...)
sort.Strings(roles)
for i, role := range roles {
if _, exists := service.policy.Roles[role]; !exists || i > 0 && roles[i-1] == role {
return nil, errors.New("access: unknown or duplicate role")
}
}
expected, err := canonicalBindingIDs(input.ExpectedBindingIDs)
if err != nil {
return nil, err
}
now := service.now().UTC()
bindings := make([]Binding, 0, len(roles))
for _, role := range roles {
id, err := randomID(service.random)
if err != nil {
return nil, err
}
bindings = append(bindings, Binding{ID: id, SubjectKind: User, SubjectID: input.UserID, Role: role, Scope: Scope{OrganizationID: input.OrganizationID}, GrantedBy: input.ActorUserID, GrantedAt: now})
}
id, err := randomID(service.random)
if err != nil {
return nil, err
}
audit := AuditEvent{ID: id, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.role.replace", ResourceType: "user", ResourceID: input.UserID, RequestID: input.RequestID, Summary: "Direct organization roles replaced", CreatedAt: now}
if err := repository.ReplaceOrganizationUserRoles(ctx, expected, bindings, service.ownerRole, audit); err != nil {
return nil, err
}
return bindings, nil
}
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MPL-2.0
package access
import (
"context"
"errors"
"slices"
"strings"
"testing"
"time"
)
type roleSetRepositoryStub struct {
repositoryStub
calls int
expected []string
roles []Binding
audit AuditEvent
}
func (r *roleSetRepositoryStub) ReplaceOrganizationUserRoles(_ context.Context, expected []string, bindings []Binding, _ string, audit AuditEvent) error {
r.calls++
r.expected, r.roles, r.audit = expected, bindings, audit
return nil
}
func TestRoleSetServiceBoundsAndCanonicalCopies(t *testing.T) {
policy := Policy{Roles: map[string]string{"owner": "Owner", "buyer": "Buyer", "billing": "Billing"}, Permissions: map[string]string{"purchase": "Purchase"}, Grants: map[string][]string{"owner": {"purchase"}, "buyer": {"purchase"}, "billing": {}}}
r := &roleSetRepositoryStub{}
service, err := New(r, policy, Options{OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
input := OrganizationUserRolesChange{OrganizationID: "organization-123", UserID: "member-12345678", ActorUserID: "owner-12345678", Roles: []string{"buyer", "billing"}, ExpectedBindingIDs: []string{"binding-second", "binding-first"}, RequestID: "request-roles"}
bindings, err := service.ReplaceOrganizationUserRoles(t.Context(), input)
if err != nil {
t.Fatal(err)
}
if r.calls != 1 || len(bindings) != 2 || bindings[0].Role != "billing" || bindings[1].Role != "buyer" || bindings[0].ID == bindings[1].ID || !slices.Equal(r.expected, []string{"binding-first", "binding-second"}) {
t.Fatalf("bindings=%v expected=%v calls=%d", bindings, r.expected, r.calls)
}
if !slices.Equal(input.Roles, []string{"buyer", "billing"}) || !slices.Equal(input.ExpectedBindingIDs, []string{"binding-second", "binding-first"}) {
t.Fatal("caller input was sorted in place")
}
if r.audit.RequestID != input.RequestID || r.audit.ActorUserID != input.ActorUserID || r.audit.ResourceID != input.UserID {
t.Fatalf("audit=%+v", r.audit)
}
for _, roles := range [][]string{nil, {"buyer", "buyer"}, {"missing"}, make([]string, 17)} {
invalid := input
invalid.Roles = roles
if _, err = service.ReplaceOrganizationUserRoles(t.Context(), invalid); err == nil {
t.Fatalf("invalid roles=%v", roles)
}
}
for _, expected := range [][]string{{"bad"}, {"binding-first", "binding-first"}, make([]string, 17)} {
invalid := input
invalid.ExpectedBindingIDs = expected
if _, err = service.ReplaceOrganizationUserRoles(t.Context(), invalid); err == nil {
t.Fatalf("invalid IDs=%v", expected)
}
}
if r.calls != 1 {
t.Fatal("invalid input reached repository")
}
legacy, err := New(&repositoryStub{}, policy, Options{OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
if _, err = legacy.ReplaceOrganizationUserRoles(t.Context(), input); !errors.Is(err, ErrRoleSetUnsupported) {
t.Fatalf("fallback=%v", err)
}
broken, err := New(r, policy, Options{OwnerRole: "owner", Random: strings.NewReader("")})
if err != nil {
t.Fatal(err)
}
if _, err = broken.ReplaceOrganizationUserRoles(t.Context(), input); err == nil || r.calls != 1 {
t.Fatal("random failure reached storage")
}
withoutOwner, err := New(r, policy, Options{Now: func() time.Time { return time.Unix(2000, 0) }})
if err != nil {
t.Fatal(err)
}
if _, err = withoutOwner.ReplaceOrganizationUserRoles(t.Context(), input); err == nil || r.calls != 1 {
t.Fatal("role set without owner boundary accepted")
}
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MPL-2.0
package auth
import (
"context"
"errors"
)
var ErrDirectoryQuery = errors.New("auth: invalid directory query")
// UserDirectoryQuery requests a bounded instance-wide identity listing. Search
// is literal text, not a query language. AfterID is an exclusive stable-ID cursor;
// Limit defaults to 50 and may not exceed 200.
type UserDirectoryQuery struct {
Search, AfterID string
Limit int
}
type UserDirectoryPage struct {
Users []User
NextID string
}
// UserDirectoryRepository is an optional administrative read capability, not an
// extension of ordinary authentication. Callers MUST authorize instance-wide
// identity access before each call. Results include incomplete/inactive accounts
// but never credentials, session material, recovery codes or permission grants.
// Pagination is a current view, not a snapshot across requests.
type UserDirectoryRepository interface {
UserDirectory(context.Context, UserDirectoryQuery) (UserDirectoryPage, error)
}
+75
View File
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: MPL-2.0
package auth
import (
"context"
"errors"
"strings"
"time"
"unicode"
"unicode/utf8"
)
var (
ErrProfileInput = errors.New("auth: invalid profile change")
ErrProfileAccess = errors.New("auth: profile session is unavailable")
ErrProfileConflict = errors.New("auth: profile changed; reload before editing")
ErrUsernameUnavailable = errors.New("auth: username is unavailable")
)
// OwnProfile contains mutable identity, not credentials or organization roles.
// Revision is independent of timestamps and increases for every profile edit.
type OwnProfile struct {
UserID, Username, Email, DisplayName string
Revision int64
}
// ProfileEdit is a trusted repository command, not an HTTP input model. The
// application must authenticate the session, validate CSRF/origin and rate-limit
// mutations. Username edits additionally require recent reauthentication (and
// any account-specific MFA). For password reauthentication, supply the verified
// hash so a concurrent password reset invalidates the write. After verified
// passkey approval, leave it empty. Do not log or serialize this command.
type ProfileEdit struct {
UserID string
SessionDigest [32]byte
ExpectedRevision int64
Field, Value string
ExpectedPasswordHash string
}
// NormalizeProfileValue validates only supported fields. Email is deliberately
// absent: verified mailbox changes need a separate pending/confirmation flow.
func NormalizeProfileValue(field, value string) (string, error) {
value = strings.TrimSpace(value)
switch field {
case "username":
if !identifierPattern.MatchString(value) {
return "", ErrProfileInput
}
case "display_name":
if value == "" || len(value) > 128 || !utf8.ValidString(value) {
return "", ErrProfileInput
}
for _, r := range value {
if unicode.IsControl(r) {
return "", ErrProfileInput
}
}
default:
return "", ErrProfileInput
}
return value, nil
}
// OwnProfileRepository is optional; no change to the authentication Repository
// interface is required. It derives access from the current session, never from
// a site-wide administrator flag. Implementations atomically recheck identity,
// session and revision, mutate one field, and append the audit. Username edits
// revoke other sessions but preserve the acting session. They never reassign
// stable IDs, memberships, passkeys, billing identities or historical records.
type OwnProfileRepository interface {
OwnProfile(context.Context, [32]byte, time.Time) (OwnProfile, error)
UpdateOwnProfile(context.Context, ProfileEdit, AuditEvent) (OwnProfile, error)
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: MPL-2.0
package auth
import "testing"
func TestNormalizeProfileValue(t *testing.T) {
for _, value := range []struct{ field, value, want string }{
{"username", " Reader.One ", "Reader.One"}, {"display_name", " Émilie ★ ", "Émilie ★"},
} {
got, err := NormalizeProfileValue(value.field, value.value)
if err != nil || got != value.want {
t.Fatalf("normalization: %q %v", got, err)
}
}
for _, value := range []struct{ field, value string }{
{"email", "new@example.test"}, {"role", "owner"}, {"username", "a"}, {"username", "foo@bar"},
{"display_name", ""}, {"display_name", "hello\x00world"}, {"display_name", "hello\nworld"}, {"display_name", string([]byte{0xff})},
} {
if _, err := NormalizeProfileValue(value.field, value.value); err == nil {
t.Fatalf("invalid field accepted: %s", value.field)
}
}
}
func FuzzProfileValue(f *testing.F) {
f.Add("username", "reader.one")
f.Add("display_name", "Émilie")
f.Add("email", "a@example.test")
f.Fuzz(func(t *testing.T, field, value string) {
normal, err := NormalizeProfileValue(field, value)
if err != nil {
return
}
if len(normal) == 0 || len(normal) > 128 {
t.Fatal("unbounded value")
}
again, err := NormalizeProfileValue(field, normal)
if err != nil || again != normal {
t.Fatal("unstable normalization")
}
})
}
+86
View File
@@ -0,0 +1,86 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Account mail protocols
`authmail` provides optional verification, confirmed address changes and password
reset. It does not deliver mail directly or own the application router. Use
`authsqlite.Store.AccountMail` for an adapter sharing the identity database and
encrypted transactional outbox. Call `MigrateMail` explicitly and require its
independent schema version before enabling these routes. Base identity schema 11
and historical commerce records are unchanged; existing mailboxes start unverified.
## Operations
- Verification requires a current unrestricted account session and confirmation
at its existing canonical mailbox. Reading/inspecting a link does not verify it.
- Address change requires the current password and that session, then separate
confirmation at both current and proposed mailboxes. The current address stays
authoritative until both confirm. No passkey-approval boolean bypass exists.
A future passkey-only path needs a separately bound fresh-approval protocol.
- Password reset is available only through the current verified mailbox of an
active, fully registered account. Request results are generic for unknown,
unverified, inactive, malformed and account-throttled addresses. A reset creates
no login, removes no passkey/recovery code and bypasses no existing MFA policy.
Tokens use 32 random bytes and purpose-bound SHA-256 digests, expire in 15 minutes,
and work once. Requests bind the user ID, canonical address, profile revision and
current password digest; address/verification requests also bind the real acting
session. A replacement request invalidates the previous link for that purpose.
Already-in-flight older mail may still arrive; an invalidated token cannot act.
The SQLite adapter rechecks authority and identity under its writer lock, including
fresh time after waits/password hashing. A changed password, profile, address,
status or acting session rejects stale requests. Address uniqueness is checked
again at final confirmation. Old-address invitations are revoked, not moved;
new-address invitations still need normal token/authority checks to be accepted.
Existing memberships, ownership and purchase snapshots retain the immutable user.
Successful reset/address changes revoke sessions, pending ceremonies, enrollment
and recovery grants, and outstanding account-mail requests. Mailbox changes notify
both addresses; resets notify the current mailbox. Account changes, notifications
and secret-free audits commit atomically. Delivery capacity/audit failures roll
everything back, leaving valid tokens retryable until their original expiry.
Per-account requests are limited to one per minute and five per hour across these
purposes. At most one pending request per account/purpose remains. Applications
must also impose IP and password-hashing concurrency limits and handle anonymous
request responses without disclosing per-account operational failures.
## Required application boundaries
- Use a configured HTTPS origin and fixed route paths. Never build links from a
request Host header. `Composer` customizes reviewed plain-text copy, not security
state, sender, recipient, headers or editable executable templates.
- Render a deliberate confirmation/reset form; only POST consumes a token. Keep
strict same-origin/CSRF protection and private/no-store responses. Prefer
`TokenInFragment` so the browser transfers the code into the deliberate POST
without placing it in HTTP/proxy request targets. Native forms can require a
same-origin referrer policy to retain a usable Origin header; fragments are
never included in referrers. Script-only flows can use no-referrer. Do not
weaken origin validation to accept opaque/null origins, and never allow link
scanners or GET requests to change account state.
- Exclude tokens, query strings, message bodies, addresses, passwords and SMTP
credentials from logs/telemetry. Restrict token-bearing pages and avoid external
analytics/resources. Debug redaction does not make structured serialization safe.
- Return anonymous request responses consistently, independent of eligibility or
SMTP acceptance. Run SMTP through the bounded outbox worker, not in the request.
- After reset/change, clear the browser's old session and return to normal login.
Preserve the application's passkey/MFA checks; mailbox control is not an owner
recovery grant. Keep printed/owner-assisted recovery separate.
- Existing Stripe receipts/billing emails are financial snapshots, not canonical
login identifiers. Do not rewrite them as part of an account email change.
The design follows the applicable OWASP guidance on
[password reset](https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html)
and [registered-email changes](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#changing-a-users-registered-email-address).
This is not a claim of a completed application security audit.
Local tests cover both confirmation orders, replay/expiry, generic reset requests,
credential/session races, competing resets, address conflicts, stable ownership,
retained factors, invitation handling, rate limits, restart and transactional
migration/audit/outbox rollback. The account packages and mail/outbox suites pass;
focused races and vet pass. The preview28 consumer dogfood also covers native
HTTP/UI integration and real BHS inbox verification/reset/both-mailbox flows.
Each application must still verify its own sender configuration, recipient
delivery and recovery operations; local provider proof is not an assurance of
external spam placement or mail reputation.
+455
View File
@@ -0,0 +1,455 @@
// SPDX-License-Identifier: MPL-2.0
// Package authmail implements mailbox verification and password-reset protocols.
// Applications own HTTP CSRF/origin checks, IP/concurrency limits and templates.
// A confirmation must be a deliberate POST, never a mail-scanner-triggered GET.
package authmail
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
stdmail "net/mail"
"net/url"
"path"
"strings"
"time"
"unicode"
"gamertan.com/web/auth"
"gamertan.com/web/mail"
)
var (
ErrInvalid = errors.New("authmail: invalid request")
ErrUnavailable = errors.New("authmail: link or account unavailable")
ErrLimited = errors.New("authmail: please wait before requesting another message")
ErrAddressUnavailable = errors.New("authmail: address cannot be used")
)
type Purpose string
const (
Verify Purpose = "verify"
Change Purpose = "change"
Reset Purpose = "reset"
Lifetime = 15 * time.Minute
)
// Subject is a repository/service boundary, not a public response or log value.
// PasswordHash is needed for reauthentication and credential-race checks.
type Subject struct {
UserID, Email, PasswordHash string
Revision int64
Verified bool
}
func (Subject) String() string { return "authmail.Subject{identity:redacted}" }
func (subject Subject) GoString() string { return subject.String() }
type Request struct {
ID, UserID, Email, NewEmail string
Purpose Purpose
Revision int64
CredentialDigest, SessionDigest, NewDigest, OldDigest [32]byte
CreatedAt, ExpiresAt time.Time
}
func (Request) String() string { return "authmail.Request{identity:redacted}" }
func (request Request) GoString() string { return request.String() }
type Pending struct {
Request Request
Subject Subject
OldToken bool
}
func (Pending) String() string { return "authmail.Pending{identity:redacted}" }
func (pending Pending) GoString() string { return pending.String() }
// Repository must atomically recheck current account/session/credential identity,
// throttling, token expiry and replay; mail intent and audit join each mutation.
// Implementations must never grant authentication or remove MFA credentials.
type Repository interface {
OwnSubject(context.Context, [32]byte, time.Time) (Subject, error)
ResetSubject(context.Context, string, time.Time) (Subject, error)
Issue(context.Context, Request, []mail.Message, auth.AuditEvent) error
Pending(context.Context, [32]byte, time.Time) (Pending, error)
Complete(context.Context, [32]byte, string, string, []mail.Message, auth.AuditEvent) (bool, error)
}
// Config contains trusted deployment values, never a request Host header. The
// two route paths must render deliberate confirmation forms. Plain-text copy is
// centralized here; applications can provide a reviewed Composer to customize it.
type Config struct {
Origin, SiteName, ConfirmPath, ResetPath, SecurityPath string
// TokenInFragment keeps link tokens out of HTTP/proxy request targets. The
// application must transfer it locally into the deliberate confirmation POST.
TokenInFragment bool
Now func() time.Time
Random io.Reader
Compose Composer
}
type MessageContent struct{ Subject, Text string }
func (MessageContent) String() string { return "authmail.MessageContent{content:redacted}" }
func (content MessageContent) GoString() string { return content.String() }
type MailIntent struct {
Kind string
SiteName, ActionURL, SecurityURL string
ExpiresAt time.Time
}
func (MailIntent) String() string { return "authmail.MailIntent{links:redacted}" }
func (intent MailIntent) GoString() string { return intent.String() }
// Composer is trusted application code, not editable template code or arbitrary
// HTML. It does not choose recipients, sender, identifiers or security state.
type Composer func(MailIntent) (MessageContent, error)
type Service struct {
repository Repository
origin *url.URL
config Config
}
func New(repository Repository, config Config) (*Service, error) {
if repository == nil || !plainHeader(config.SiteName, 100) {
return nil, ErrInvalid
}
origin, err := url.Parse(config.Origin)
if err != nil || origin.Scheme != "https" || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || (origin.Path != "" && origin.Path != "/") || origin.Opaque != "" {
return nil, ErrInvalid
}
for _, route := range []string{config.ConfirmPath, config.ResetPath, config.SecurityPath} {
if !strings.HasPrefix(route, "/") || strings.HasPrefix(route, "//") || strings.ContainsAny(route, "?#\\%") || !plainHeader(route, 256) || strings.TrimSuffix(route, "/") != path.Clean(route) {
return nil, ErrInvalid
}
}
if config.Now == nil {
config.Now = time.Now
}
if config.Random == nil {
config.Random = rand.Reader
}
if config.Compose == nil {
config.Compose = DefaultComposer
}
return &Service{repository: repository, origin: origin, config: config}, nil
}
func plainHeader(value string, limit int) bool {
if strings.TrimSpace(value) == "" || len(value) > limit {
return false
}
for _, r := range value {
if unicode.IsControl(r) {
return false
}
}
return true
}
// NormalizeEmail preserves this identity store's case-insensitive mailbox rule.
// SMTPUTF8, display-name syntax and mailbox-provider alias rewriting are absent.
func NormalizeEmail(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
if !plainHeader(value, 254) {
return "", ErrInvalid
}
for _, r := range value {
if r > 127 {
return "", ErrInvalid
}
}
address, err := stdmail.ParseAddress(value)
if err != nil || address.Name != "" || address.Address != value || !strings.Contains(value, "@") {
return "", ErrInvalid
}
return value, nil
}
func TokenDigest(token string) ([32]byte, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(raw) != 32 || base64.RawURLEncoding.EncodeToString(raw) != token {
return [32]byte{}, ErrUnavailable
}
return sha256.Sum256([]byte("gwf.authmail.v1:" + token)), nil
}
func (service *Service) random(size int) (string, error) {
raw := make([]byte, size)
if _, err := io.ReadFull(service.config.Random, raw); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func (service *Service) link(route, token string) string {
link := *service.origin
link.Path = route
link.RawQuery = ""
if token != "" {
if service.config.TokenInFragment {
link.Fragment = url.Values{"token": {token}}.Encode()
} else {
link.RawQuery = url.Values{"token": {token}}.Encode()
}
}
return link.String()
}
func (service *Service) message(kind, recipient, token string, now time.Time) (mail.Message, error) {
route := service.config.ConfirmPath
if kind == "reset" {
route = service.config.ResetPath
}
intent := MailIntent{Kind: kind, SiteName: service.config.SiteName, SecurityURL: service.link(service.config.SecurityPath, ""), ExpiresAt: now.Add(Lifetime)}
if token != "" {
intent.ActionURL = service.link(route, token)
}
content, err := service.config.Compose(intent)
if err != nil {
return mail.Message{}, ErrInvalid
}
id, err := service.random(18)
if err != nil {
return mail.Message{}, err
}
message := mail.Message{ID: "mail_" + id, To: recipient, Subject: content.Subject, Text: content.Text, CreatedAt: now}
if message.Validate() != nil {
return mail.Message{}, ErrInvalid
}
return message, nil
}
func (service *Service) audit(action, userID string, now time.Time) (auth.AuditEvent, error) {
id, err := service.random(18)
if err != nil {
return auth.AuditEvent{}, err
}
actor := userID
if strings.HasPrefix(action, "reset") {
actor = ""
}
return auth.AuditEvent{ID: "mailaudit_" + id, ActorUserID: actor, Action: "auth.mail." + action, ResourceType: "user", ResourceID: userID, Summary: "Account mail operation", CreatedAt: now}, nil
}
// Status requires a current unrestricted session; it is not an address lookup.
func (service *Service) Status(ctx context.Context, session [32]byte) (bool, error) {
subject, err := service.repository.OwnSubject(ctx, session, service.config.Now().UTC())
return subject.Verified, err
}
func (service *Service) RequestVerification(ctx context.Context, session [32]byte) error {
now := service.config.Now().UTC().Truncate(time.Second)
subject, err := service.repository.OwnSubject(ctx, session, now)
if err != nil {
return err
}
if subject.Verified {
return nil
}
return service.issue(ctx, subject, session, Verify, "", now)
}
// RequestChange requires the current password and confirmations at BOTH old and
// new mailboxes. It does not change the canonical address immediately. Passkey-
// only reauthentication would be a separate, operation-bound protocol, not a bool.
func (service *Service) RequestChange(ctx context.Context, session [32]byte, currentPassword, newEmail string) error {
if len(currentPassword) > 1024 {
return ErrInvalid
}
now := service.config.Now().UTC().Truncate(time.Second)
subject, err := service.repository.OwnSubject(ctx, session, now)
if err != nil {
return err
}
if !auth.VerifyPassword(subject.PasswordHash, currentPassword) {
return auth.ErrInvalidCredentials
}
newEmail, err = NormalizeEmail(newEmail)
if err != nil {
return err
}
current, err := NormalizeEmail(subject.Email)
if err != nil || newEmail == current {
return ErrInvalid
}
return service.issue(ctx, subject, session, Change, newEmail, now)
}
// RequestReset has the same result for unknown, unverified, inactive, malformed
// and account-rate-limited addresses. Applications must likewise keep responses
// generic, rate-limit by IP and avoid response timing tied to actual SMTP work.
func (service *Service) RequestReset(ctx context.Context, email string) error {
email, err := NormalizeEmail(email)
if err != nil {
return nil
}
now := service.config.Now().UTC().Truncate(time.Second)
subject, err := service.repository.ResetSubject(ctx, email, now)
if errors.Is(err, ErrUnavailable) {
return nil
}
if err != nil {
return err
}
err = service.issue(ctx, subject, [32]byte{}, Reset, "", now)
if errors.Is(err, ErrLimited) || errors.Is(err, ErrUnavailable) {
return nil
}
return err
}
func (service *Service) issue(ctx context.Context, subject Subject, session [32]byte, purpose Purpose, newEmail string, now time.Time) error {
email, err := NormalizeEmail(subject.Email)
if err != nil {
return ErrUnavailable
}
id, err := service.random(18)
if err != nil {
return err
}
token, err := service.random(32)
if err != nil {
return err
}
digest, _ := TokenDigest(token)
request := Request{ID: "request_" + id, UserID: subject.UserID, Email: email, NewEmail: newEmail, Purpose: purpose, Revision: subject.Revision, CredentialDigest: sha256.Sum256([]byte(subject.PasswordHash)), SessionDigest: session, NewDigest: digest, CreatedAt: now, ExpiresAt: now.Add(Lifetime)}
kind, target := string(purpose), email
if purpose == Change {
kind, target = "change-new", newEmail
}
message, err := service.message(kind, target, token, now)
if err != nil {
return err
}
messages := []mail.Message{message}
if purpose == Change {
oldToken, err := service.random(32)
if err != nil {
return err
}
request.OldDigest, _ = TokenDigest(oldToken)
oldMessage, err := service.message("change-old", email, oldToken, now)
if err != nil {
return err
}
messages = append(messages, oldMessage)
}
audit, err := service.audit(string(purpose)+".request", subject.UserID, now)
if err != nil {
return err
}
return service.repository.Issue(ctx, request, messages, audit)
}
// Inspect is read-only and intentionally returns no account or address details.
func (service *Service) Inspect(ctx context.Context, token string) (Purpose, error) {
digest, err := TokenDigest(token)
if err != nil {
return "", err
}
pending, err := service.repository.Pending(ctx, digest, service.config.Now().UTC())
if err != nil {
return "", err
}
return pending.Request.Purpose, nil
}
// Confirm consumes a verification/change token. false means the other mailbox
// still needs confirmation. It never issues a login or changes a password.
func (service *Service) Confirm(ctx context.Context, token string) (bool, error) {
return service.complete(ctx, token, "", false)
}
// ResetPassword requires a reset token and retains passkeys/recovery codes. The
// application must send the user through its normal sign-in and MFA afterwards.
func (service *Service) ResetPassword(ctx context.Context, token, password string) error {
if err := auth.ValidatePassword(password); err != nil {
return err
}
_, err := service.complete(ctx, token, password, true)
return err
}
func (service *Service) complete(ctx context.Context, token, password string, reset bool) (bool, error) {
digest, err := TokenDigest(token)
if err != nil {
return false, err
}
now := service.config.Now().UTC().Truncate(time.Second)
pending, err := service.repository.Pending(ctx, digest, now)
if err != nil {
return false, err
}
if (pending.Request.Purpose == Reset) != reset {
return false, ErrUnavailable
}
var hash string
var notices []mail.Message
if reset {
if auth.VerifyPassword(pending.Subject.PasswordHash, password) {
return false, auth.ErrPasswordUnchanged
}
hash, err = auth.HashPasswordWithRandom(password, service.config.Random)
if err != nil {
return false, err
}
notice, err := service.message("password-changed", pending.Request.Email, "", now)
if err != nil {
return false, err
}
notices = append(notices, notice)
} else if pending.Request.Purpose == Change {
for _, recipient := range []string{pending.Request.Email, pending.Request.NewEmail} {
notice, err := service.message("email-changed", recipient, "", now)
if err != nil {
return false, err
}
notices = append(notices, notice)
}
}
audit, err := service.audit(string(pending.Request.Purpose)+".confirm", pending.Subject.UserID, now)
if err != nil {
return false, err
}
return service.repository.Complete(ctx, digest, pending.Request.ID, hash, notices, audit)
}
func DefaultComposer(intent MailIntent) (MessageContent, error) {
var title, text string
switch intent.Kind {
case "verify":
title, text = "Confirm your email", "Confirm that this is the email address you'd like to use for your account."
case "reset":
title, text = "Reset your password", "Someone requested a password reset for your account. If that was you, choose a new password using the link below."
case "change-new":
title, text = "Confirm your new email", "Confirm this address to continue your account email change. Your current mailbox also needs to approve the change."
case "change-old":
title, text = "Approve your email change", "Someone who confirmed your current password requested an account email change. Approve it only if you made this request; the new mailbox must confirm too."
case "password-changed":
title, text = "Your password was changed", "Your account password was reset. Existing sessions were signed out. Your passkeys and recovery codes were not removed."
case "email-changed":
title, text = "Your account email was changed", "Both mailboxes confirmed your account email change. Existing sessions were signed out. Your purchases and memberships still belong to the same account."
default:
return MessageContent{}, ErrInvalid
}
if intent.ActionURL != "" {
text += fmt.Sprintf("\n\n%s\n\nThis link expires at %s. Opening it alone does not change your account; the page asks you to confirm.", intent.ActionURL, intent.ExpiresAt.UTC().Format(time.RFC1123))
}
if intent.ActionURL != "" {
text += "\n\nIf you did not request this, do not confirm it."
} else {
text += "\n\nIf this was not you, please contact the site's support team promptly."
}
text += "\nVisit your account security page or contact support:\n" + intent.SecurityURL + "\n\n" + intent.SiteName + "\n"
return MessageContent{Subject: title + " — " + intent.SiteName, Text: text}, nil
}
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: MPL-2.0
package authmail
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"testing"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/mail"
)
type unavailableRepository struct{}
func (unavailableRepository) OwnSubject(context.Context, [32]byte, time.Time) (Subject, error) {
return Subject{}, ErrUnavailable
}
func (unavailableRepository) ResetSubject(context.Context, string, time.Time) (Subject, error) {
return Subject{}, ErrUnavailable
}
func (unavailableRepository) Issue(context.Context, Request, []mail.Message, auth.AuditEvent) error {
return ErrUnavailable
}
func (unavailableRepository) Pending(context.Context, [32]byte, time.Time) (Pending, error) {
return Pending{}, ErrUnavailable
}
func (unavailableRepository) Complete(context.Context, [32]byte, string, string, []mail.Message, auth.AuditEvent) (bool, error) {
return false, ErrUnavailable
}
func testConfig() Config {
return Config{Origin: "https://example.test", SiteName: "Example", ConfirmPath: "/account/email/confirm/", ResetPath: "/password/reset/", SecurityPath: "/account/security/"}
}
func TestTrustedOriginAndRouteValidation(t *testing.T) {
for _, origin := range []string{"http://example.test", "https://user:password@example.test", "https://example.test/path", "https://example.test/?query=1", "https://example.test/#fragment", "javascript:alert(1)", "//example.test", "https://"} {
config := testConfig()
config.Origin = origin
if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) {
t.Fatalf("invalid origin accepted: %q", origin)
}
}
for _, route := range []string{"//evil.test/path", "/path?token=1", "/path#fragment", "/a/../b", "relative/path", "/bad\\path", "/bad\npath", "/%2f%2fevil.test"} {
config := testConfig()
config.ConfirmPath = route
if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) {
t.Fatalf("invalid route accepted: %q", route)
}
}
if _, err := New(nil, testConfig()); !errors.Is(err, ErrInvalid) {
t.Fatal("nil repository")
}
service, err := New(unavailableRepository{}, testConfig())
if err != nil {
t.Fatal(err)
}
if got := service.link("/password/reset/", "a+/&b"); got != "https://example.test/password/reset/?token=a%2B%2F%26b" {
t.Fatalf("link encoding: %s", got)
}
fragmentConfig := testConfig()
fragmentConfig.TokenInFragment = true
fragmentService, err := New(unavailableRepository{}, fragmentConfig)
if err != nil {
t.Fatal(err)
}
if got := fragmentService.link("/password/reset/", "fixture-token"); got != "https://example.test/password/reset/#token=fixture-token" {
t.Fatal("fragment mode exposed token in request target")
}
config := testConfig()
config.SiteName = "Name\nInjected: header"
if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) {
t.Fatal("site name injection")
}
}
func TestMailboxAndTokenValidation(t *testing.T) {
if got, err := NormalizeEmail(" READER@Example.Test "); err != nil || got != "reader@example.test" {
t.Fatalf("normalization %q %v", got, err)
}
for _, email := range []string{"Name <reader@example.test>", "reader@example.test\nBcc: other@example.test", "ü@example.test", "a,b@example.test", "missing-at", strings.Repeat("x", 255) + "@example.test"} {
if _, err := NormalizeEmail(email); err == nil {
t.Fatalf("invalid address accepted: %q", email)
}
}
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
first, err := TokenDigest(token)
if err != nil || first == ([32]byte{}) {
t.Fatal("valid token rejected")
}
for _, value := range []string{"", token + "=", token[:42], strings.Repeat("a", 200), " " + token} {
if _, err := TokenDigest(value); !errors.Is(err, ErrUnavailable) {
t.Fatal("malformed token accepted")
}
}
service, err := New(unavailableRepository{}, testConfig())
if err != nil {
t.Fatal(err)
}
for _, value := range []string{"not-an-email", "unknown@example.test"} {
if err := service.RequestReset(t.Context(), value); err != nil {
t.Fatal("reset enumeration", err)
}
}
if _, err = service.Inspect(t.Context(), token); !errors.Is(err, ErrUnavailable) {
t.Fatal(err)
}
if _, err = service.Confirm(t.Context(), token); !errors.Is(err, ErrUnavailable) {
t.Fatal(err)
}
}
func TestDefaultAndCustomCopyDoesNotControlEnvelope(t *testing.T) {
config := testConfig()
config.Compose = func(intent MailIntent) (MessageContent, error) {
return MessageContent{Subject: "Custom subject", Text: "Custom body: " + intent.ActionURL}, nil
}
service, err := New(unavailableRepository{}, config)
if err != nil {
t.Fatal(err)
}
message, err := service.message("verify", "recipient@example.test", "token", time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if message.To != "recipient@example.test" || message.Subject != "Custom subject" || !strings.Contains(message.Text, "https://example.test/account/email/confirm/?token=token") {
t.Fatal("custom copy bypassed fixed envelope/origin")
}
config.Compose = func(MailIntent) (MessageContent, error) {
return MessageContent{Subject: "bad\nBcc: another@example.test", Text: "body"}, nil
}
service, err = New(unavailableRepository{}, config)
if err != nil {
t.Fatal(err)
}
if _, err = service.message("verify", "recipient@example.test", "token", time.Now().UTC()); !errors.Is(err, ErrInvalid) {
t.Fatal("custom header injection")
}
for _, kind := range []string{"verify", "reset", "change-new", "change-old", "password-changed", "email-changed"} {
content, err := DefaultComposer(MailIntent{Kind: kind, SiteName: "Example", SecurityURL: "https://example.test/security/", ActionURL: "https://example.test/confirm/", ExpiresAt: time.Now().UTC()})
if err != nil || content.Subject == "" || content.Text == "" {
t.Fatal("missing copy", kind)
}
}
if _, err = DefaultComposer(MailIntent{Kind: "unknown"}); !errors.Is(err, ErrInvalid) {
t.Fatal("unknown intent")
}
for _, value := range []any{Subject{Email: "private@example.test", PasswordHash: "secret-hash"}, Request{Email: "private@example.test"}, Pending{Subject: Subject{Email: "private@example.test"}}} {
if strings.Contains(fmt.Sprintf("%v %#v", value, value), "private@example.test") {
t.Fatal("sensitive debug output")
}
}
}
+157
View File
@@ -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)
}
}
+311 -17
View File
@@ -16,14 +16,19 @@ import (
"strings"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
const DefaultCodeCount = 10
var (
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
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")
)
type Grant struct {
@@ -39,24 +44,103 @@ type Repository interface {
TakeRecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error)
}
// PasskeyRepository adds the transactional boundary required to finish a
// password-plus-recovery-code flow without issuing a normal session.
type PasskeyRepository interface {
Repository
RecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error)
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)
FinishRecoveryRegistration(context.Context, string, []byte, []byte, authwebauthn.RegistrationCommit) (authwebauthn.Credential, error)
}
// PasskeyCompletion contains the public credential, digest-only replacement
// codes, and secret-free audits committed after a recovery ceremony.
type PasskeyCompletion struct {
GrantDigest [32]byte
Credential authwebauthn.Credential
RecoveryDigests [][32]byte
PasskeyAudit auth.AuditEvent
RecoveryAudit auth.AuditEvent
CompletedAt time.Time
}
// PasskeyFinishResult returns the verified credential and the new plaintext
// recovery codes. Applications must display the codes once and retain none.
type PasskeyFinishResult struct {
Credential authwebauthn.Credential
RecoveryCodes []string
}
type PasswordVerifier interface {
VerifyPassword(context.Context, string, string) (auth.User, error)
}
type Options struct {
Random io.Reader
Now func() time.Time
CodeCount int
GrantLifetime time.Duration
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
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) {
@@ -75,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}, 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
@@ -133,10 +327,74 @@ func (service *Service) Begin(ctx context.Context, identifier, password, code st
}
func (service *Service) TakeGrant(ctx context.Context, raw string) (auth.User, error) {
if len(raw) < 32 || len(raw) > 128 {
return auth.User{}, ErrGrantNotFound
digest, err := grantDigest(raw)
if err != nil {
return auth.User{}, err
}
return service.repository.TakeRecoveryGrant(ctx, sha256.Sum256([]byte(raw)), service.now().UTC())
return service.repository.TakeRecoveryGrant(ctx, digest, service.now().UTC())
}
// BeginPasskey starts a ceremony only for a live restricted recovery grant.
// The raw grant remains application-held so a failed or interrupted ceremony
// can be restarted until the grant expires.
func (service *Service) BeginPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) {
repository, ok := service.repository.(PasskeyRepository)
if !ok || service.passkeys == nil {
return authwebauthn.BeginResult{}, ErrPasskeyUnavailable
}
digest, err := grantDigest(rawGrant)
if err != nil {
return authwebauthn.BeginResult{}, err
}
user, err := repository.RecoveryGrant(ctx, digest, service.now().UTC())
if err != nil {
return authwebauthn.BeginResult{}, err
}
return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant))
}
// FinishPasskey consumes the grant only inside the transaction that stores the
// verified passkey and a fresh recovery-code set. It never issues a session.
func (service *Service) FinishPasskey(ctx context.Context, rawGrant, ceremonyToken string, response []byte) (PasskeyFinishResult, error) {
repository, ok := service.repository.(PasskeyRepository)
if !ok || service.passkeys == nil {
return PasskeyFinishResult{}, ErrPasskeyUnavailable
}
digest, err := grantDigest(rawGrant)
if err != nil {
return PasskeyFinishResult{}, err
}
user, err := repository.RecoveryGrant(ctx, digest, service.now().UTC())
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: recovery identity mismatch")
}
completedAt := service.now().UTC()
auditID, auditErr := token(service.random, 18)
if auditErr != nil {
return auditErr
}
recoveryAudit := auth.AuditEvent{ID: auditID, ActorUserID: user.ID, Action: "auth.recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Account recovery enrolled a replacement passkey and replaced the recovery-code set.", CreatedAt: completedAt}
return repository.CompletePasskeyRecovery(commitContext, PasskeyCompletion{
GrantDigest: digest,
Credential: verified,
RecoveryDigests: digests,
PasskeyAudit: passkeyAudit,
RecoveryAudit: recoveryAudit,
CompletedAt: completedAt,
})
})
if err != nil {
return PasskeyFinishResult{}, err
}
return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil
}
func GenerateCodeSet(random io.Reader, count int) ([]string, [][32]byte, error) {
@@ -173,6 +431,16 @@ func DigestCode(code string) ([32]byte, error) {
return sha256.Sum256(append([]byte("gamertan-web-recovery-code-v1\x00"), decoded...)), nil
}
func grantDigest(raw string) ([32]byte, error) {
if len(raw) < 32 || len(raw) > 128 {
return [32]byte{}, ErrGrantNotFound
}
if _, err := base64.RawURLEncoding.DecodeString(raw); err != nil {
return [32]byte{}, ErrGrantNotFound
}
return sha256.Sum256([]byte(raw)), nil
}
func token(random io.Reader, size int) (string, error) {
value := make([]byte, size)
if _, err := io.ReadFull(random, value); err != nil {
@@ -180,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
}
+112
View File
@@ -3,6 +3,10 @@
package authrecovery_test
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"path/filepath"
"strings"
@@ -12,6 +16,8 @@ import (
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
"gamertan.com/web/authsqlite"
"gamertan.com/web/authwebauthn"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
)
func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) {
@@ -61,6 +67,112 @@ func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) {
}
}
func TestPasskeyRecoveryAtomicallyReplacesCodesWithoutIssuingSession(t *testing.T) {
now := time.Date(2026, 9, 3, 13, 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)
}
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.passkey", Email: "recover-passkey@example.test", DisplayName: "Recover Passkey", Password: "correct horse battery staple"})
if 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: user.ID, Label: "Existing passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "existing-passkey-audit", ActorUserID: user.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Existing passkey fixture.", CreatedAt: now}); err != nil {
t.Fatal(err)
}
passkeys := &passkeyRecoveryStub{now: now, credentialID: existingID}
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys})
if err != nil {
t.Fatal(err)
}
oldCodes, err := recovery.ReplaceCodes(t.Context(), user.ID, user.ID)
if err != nil {
t.Fatal(err)
}
_, grant, err := recovery.Begin(t.Context(), user.Email, "correct horse battery staple", oldCodes[0])
if err != nil {
t.Fatal(err)
}
begin, err := recovery.BeginPasskey(t.Context(), grant, "Replacement passkey")
if err != nil || begin.CeremonyToken == "" || passkeys.userID != user.ID || passkeys.beginBinding != grant {
t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err)
}
if _, err = recovery.FinishPasskey(t.Context(), grant, begin.CeremonyToken, []byte(`{"fixture":true}`)); err == nil {
t.Fatal("duplicate credential unexpectedly committed")
}
if _, err = recovery.BeginPasskey(t.Context(), grant, "Retry replacement"); err != nil {
t.Fatalf("failed completion consumed recovery grant: %v", err)
}
lateSession, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
passkeys.credentialID = bytes.Repeat([]byte{8}, 32)
result, err := recovery.FinishPasskey(t.Context(), grant, "retry-ceremony-token", []byte(`{"fixture":true}`))
if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount || !bytes.Equal(result.Credential.ID, passkeys.credentialID) {
t.Fatalf("result=%+v err=%v", result, err)
}
if passkeys.finishBinding != grant {
t.Fatal("finish ceremony was not bound to the restricted recovery grant")
}
if _, err = recovery.TakeGrant(t.Context(), grant); !errors.Is(err, authrecovery.ErrGrantNotFound) {
t.Fatalf("completed grant replay err=%v", err)
}
if _, err = authService.Session(t.Context(), lateSession); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session created during recovery survived completion: %v", err)
}
if _, _, err = recovery.Begin(t.Context(), user.Email, "correct horse battery staple", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("old recovery-code set survived completion: %v", err)
}
if _, newGrant, beginErr := recovery.Begin(t.Context(), user.Email, "correct horse battery staple", result.RecoveryCodes[0]); beginErr != nil || newGrant == "" {
t.Fatalf("new recovery code unavailable: grant=%q err=%v", newGrant, beginErr)
}
credentials, err := store.CredentialsByUserID(t.Context(), user.ID)
if err != nil || len(credentials) != 2 {
t.Fatalf("credentials=%+v err=%v", credentials, err)
}
}
type passkeyRecoveryStub struct {
now time.Time
userID string
credentialID []byte
beginBinding string
finishBinding string
}
func (stub *passkeyRecoveryStub) BeginRecoveryRegistration(_ context.Context, userID, _ string, binding []byte) (authwebauthn.BeginResult, error) {
stub.userID = userID
stub.beginBinding = string(binding)
return authwebauthn.BeginResult{CeremonyToken: "recovery-ceremony-token", PublicKey: json.RawMessage(`{"challenge":"fixture"}`), ExpiresAt: stub.now.Add(5 * time.Minute)}, nil
}
func (stub *passkeyRecoveryStub) FinishRecoveryRegistration(ctx context.Context, _ string, binding, _ []byte, commit authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) {
stub.finishBinding = string(binding)
encoded, err := json.Marshal(wa.Credential{ID: stub.credentialID, PublicKey: []byte{1, 2, 3}})
if err != nil {
return authwebauthn.Credential{}, err
}
credential := authwebauthn.Credential{ID: append([]byte(nil), stub.credentialID...), UserID: stub.userID, Label: "Replacement passkey", Data: encoded, CreatedAt: stub.now}
audit := auth.AuditEvent{ID: "recovery-passkey-audit", ActorUserID: stub.userID, Action: "auth.recovery.passkey", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(stub.credentialID), Summary: "A replacement passkey was enrolled during account recovery.", CreatedAt: stub.now}
if err = commit(ctx, credential, audit); err != nil {
return authwebauthn.Credential{}, err
}
return credential, nil
}
type counterReader struct{ value byte }
func (reader *counterReader) Read(target []byte) (int, error) {
+185
View File
@@ -6,6 +6,7 @@ import (
"context"
"database/sql"
"errors"
"slices"
"time"
"gamertan.com/web/access"
@@ -134,6 +135,190 @@ 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 {
return store.replaceOrganizationUserRoles(ctx, expected, []access.Binding{replacement}, ownerRole, audit, false)
}
func (store *Store) ReplaceOrganizationUserRoles(ctx context.Context, expected []string, replacements []access.Binding, ownerRole string, audit access.AuditEvent) error {
return store.replaceOrganizationUserRoles(ctx, expected, replacements, ownerRole, audit, true)
}
func (store *Store) replaceOrganizationUserRoles(ctx context.Context, expected []string, replacements []access.Binding, ownerRole string, audit access.AuditEvent, requireOwner bool) error {
if len(replacements) < 1 || len(replacements) > 16 {
return errors.New("authsqlite: invalid organization role set")
}
replacement := replacements[0]
roles := make([]string, 0, len(replacements))
ids := make(map[string]bool, len(replacements))
for _, value := range replacements {
if !validOrganizationRoleReplacement(expected, value, ownerRole, audit) || value.SubjectID != replacement.SubjectID || value.Scope != replacement.Scope || value.GrantedBy != replacement.GrantedBy || !value.GrantedAt.Equal(replacement.GrantedAt) || ids[value.ID] || slices.Contains(roles, value.Role) {
return errors.New("authsqlite: invalid organization role set")
}
roles = append(roles, value.Role)
ids[value.ID] = true
}
slices.Sort(roles)
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
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' AND u.registration_pending=0)`, 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' AND u.registration_pending=0
WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil {
return err
}
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
}
slices.Sort(currentRoles)
if slices.Equal(currentRoles, roles) {
return access.ErrRoleUnchanged
}
if requireOwner || slices.Contains(roles, ownerRole) || slices.Contains(currentRoles, ownerRole) {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, replacement.Scope.OrganizationID, replacement.GrantedBy, ownerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return access.ErrOwnerAuthority
}
}
if !slices.Contains(roles, ownerRole) && slices.Contains(currentRoles, ownerRole) {
var otherOwners int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
FROM gwf_access_bindings b
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
AND b.revoked_at IS NULL`, replacement.Scope.OrganizationID, replacement.SubjectID, ownerRole).Scan(&otherOwners); err != nil {
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
}
for _, value := range replacements {
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at)
SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, value.ID, value.Scope.OrganizationID, value.SubjectID, value.Role, value.GrantedBy, value.GrantedAt.Unix(), value.Role)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: replacement role has not been seeded")
}
}
if err = appendAccessAudit(ctx, tx, audit); err != nil {
return err
}
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")
+190
View File
@@ -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, &registrationPending, &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
}
+67
View File
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"gamertan.com/web/bootstrap"
)
// CreateInitialOwner commits the root-local bootstrap across identity,
// enrollment, organization, membership, owner access, and all audit records.
func (store *Store) CreateInitialOwner(ctx context.Context, setup bootstrap.Setup) error {
user := setup.User
organization := setup.Organization
membership := setup.Membership
binding := setup.OwnerBinding
if !validPasskeyUser(user) || !validEnrollment(setup.Enrollment) || setup.Enrollment.UserID != user.ID ||
!validOrganization(organization) || organization.Personal || organization.Status != "active" || organization.Revision != 1 ||
membership.OrganizationID != organization.ID || membership.UserID != user.ID || membership.Status != "active" || membership.JoinedAt.IsZero() ||
!validOwnerBinding(binding, organization.ID, user.ID) ||
!validAuditEvent(setup.AuthAudit) || setup.AuthAudit.ActorUserID != user.ID || setup.AuthAudit.Action != "auth.passkey.bootstrap" || setup.AuthAudit.ResourceType != "user" || setup.AuthAudit.ResourceID != user.ID ||
!validOrganizationAudit(setup.OrganizationAudit, organization.ID) || setup.OrganizationAudit.ActorUserID != user.ID || setup.OrganizationAudit.Action != "organization.bootstrap" || setup.OrganizationAudit.ResourceType != "organization" || setup.OrganizationAudit.ResourceID != organization.ID ||
!validAccessAudit(setup.AccessAudit) || setup.AccessAudit.OrganizationID != organization.ID || setup.AccessAudit.ActorUserID != user.ID || setup.AccessAudit.Action != "access.binding.grant" || setup.AccessAudit.ResourceType != "binding" || setup.AccessAudit.ResourceID != binding.ID {
return errors.New("authsqlite: invalid initial owner bootstrap")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, setup.Enrollment.Digest[:], user.ID, setup.Enrollment.CreatedAt.Unix(), setup.Enrollment.ExpiresAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,0,NULL,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, organization.ID, user.ID, membership.Status, membership.JoinedAt.Unix()); 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=?`, binding.ID, organization.ID, user.ID, binding.Role, user.ID, binding.GrantedAt.Unix(), binding.Role)
if err != nil {
return err
}
if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 {
if rowsErr != nil {
return rowsErr
}
return errors.New("authsqlite: initial owner role has not been seeded")
}
if err = appendAudit(ctx, tx, setup.AuthAudit); err != nil {
return err
}
if err = appendOrganizationAudit(ctx, tx, setup.OrganizationAudit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, setup.AccessAudit); err != nil {
return err
}
return tx.Commit()
}
var _ bootstrap.Repository = (*Store)(nil)
+108
View File
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"errors"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
"gamertan.com/web/bootstrap"
)
func TestInitialOwnerBootstrapCommitsEveryBoundary(t *testing.T) {
store, err := Open(t.TempDir() + "/bootstrap.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
policy := access.Policy{Roles: map[string]string{"home.owner": "Own the home organization"}, Permissions: map[string]string{"home.manage": "Manage the home organization"}, Grants: map[string][]string{"home.owner": {"home.manage"}}}
accessService, err := access.New(store, policy, access.Options{})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC)
service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
created, err := service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"})
if err != nil {
t.Fatal(err)
}
user, err := store.UserByID(t.Context(), created.User.ID)
if err != nil || user.Email != "cole@example.test" {
t.Fatalf("user=%+v err=%v", user, err)
}
organization, err := store.OrganizationByID(t.Context(), created.Organization.ID)
if err != nil || organization.Personal || organization.Slug != "gamertan" {
t.Fatalf("organization=%+v err=%v", organization, err)
}
memberships, err := store.MembershipsForUser(t.Context(), user.ID)
if err != nil || len(memberships) != 1 || memberships[0].OrganizationID != organization.ID {
t.Fatalf("memberships=%+v err=%v", memberships, err)
}
decision, err := accessService.Authorize(t.Context(), user.ID, access.Scope{OrganizationID: organization.ID}, "home.manage")
if err != nil || !decision.Allowed || decision.Role != "home.owner" {
t.Fatalf("decision=%+v err=%v", decision, err)
}
passkeyService := testBootstrapPasskeyService(t, store, now)
begin, err := passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Initial passkey")
if err != nil || begin.CeremonyToken == "" {
t.Fatalf("begin=%+v err=%v", begin, err)
}
if _, err = passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
t.Fatalf("enrollment replay err=%v", err)
}
var authAudits, accessAudits int
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&authAudits); err != nil {
t.Fatal(err)
}
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&accessAudits); err != nil {
t.Fatal(err)
}
if authAudits != 1 || accessAudits != 2 {
t.Fatalf("auth audits=%d access audits=%d", authAudits, accessAudits)
}
}
func TestInitialOwnerBootstrapRollsBackWithoutSeededRole(t *testing.T) {
store, err := Open(t.TempDir() + "/bootstrap.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC)
service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
if _, err = service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"}); err == nil {
t.Fatal("bootstrap succeeded without seeded role")
}
for _, table := range []string{"gwf_users", "gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_passkey_enrollment_tokens", "gwf_audit_events", "gwf_access_audit_events"} {
var count int
if queryErr := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); queryErr != nil || count != 0 {
t.Fatalf("table=%s count=%d err=%v", table, count, queryErr)
}
}
}
func testBootstrapPasskeyService(t *testing.T, store *Store, now time.Time) *authwebauthn.Service {
t.Helper()
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "example.test", RPDisplayName: "Example", Origin: "https://example.test", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
return service
}
+95
View File
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"strings"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/organizations"
)
var _ auth.UserDirectoryRepository = (*Store)(nil)
var _ organizations.DirectoryRepository = (*Store)(nil)
// UserDirectory is an administrative read; the adapter cannot infer application
// authorization. Search covers ID, username, email and display name. SQLite LIKE
// folds ASCII case; non-ASCII display-name text matches with its original case.
func (store *Store) UserDirectory(ctx context.Context, query auth.UserDirectoryQuery) (auth.UserDirectoryPage, error) {
pattern, limit, valid := directoryQuery(query.Search, query.AfterID, query.Limit)
if !valid {
return auth.UserDirectoryPage{}, auth.ErrDirectoryQuery
}
rows, err := store.db.QueryContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at
FROM gwf_users WHERE id>? AND (?='' OR id=? OR username_normalized LIKE ? ESCAPE '\' OR email_normalized LIKE ? ESCAPE '\' OR display_name LIKE ? ESCAPE '\')
ORDER BY id LIMIT ?`, query.AfterID, strings.TrimSpace(query.Search), strings.TrimSpace(query.Search), strings.ToLower(pattern), strings.ToLower(pattern), pattern, limit+1)
if err != nil {
return auth.UserDirectoryPage{}, err
}
defer rows.Close()
page := auth.UserDirectoryPage{Users: make([]auth.User, 0, limit)}
for rows.Next() {
user, err := scanPasskeyUser(rows)
if err != nil {
return auth.UserDirectoryPage{}, err
}
page.Users = append(page.Users, user)
}
if err = rows.Err(); err != nil {
return auth.UserDirectoryPage{}, err
}
if len(page.Users) > limit {
page.Users = page.Users[:limit]
page.NextID = page.Users[limit-1].ID
}
return page, nil
}
// OrganizationDirectory reads all personal/business and active/archived records.
// It does not join membership, grant access, or choose a merchant. Search covers
// exact ID and literal slug/name text using SQLite's ASCII case folding.
func (store *Store) OrganizationDirectory(ctx context.Context, query organizations.DirectoryQuery) (organizations.DirectoryPage, error) {
pattern, limit, valid := directoryQuery(query.Search, query.AfterID, query.Limit)
if !valid {
return organizations.DirectoryPage{}, organizations.ErrDirectoryQuery
}
rows, err := store.db.QueryContext(ctx, `SELECT id,slug,name,status,personal,revision,created_at,updated_at
FROM gwf_organizations WHERE id>? AND (?='' OR id=? OR slug LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\')
ORDER BY id LIMIT ?`, query.AfterID, strings.TrimSpace(query.Search), strings.TrimSpace(query.Search), pattern, pattern, limit+1)
if err != nil {
return organizations.DirectoryPage{}, err
}
defer rows.Close()
page := organizations.DirectoryPage{Organizations: make([]organizations.Organization, 0, limit)}
for rows.Next() {
var value organizations.Organization
var created, updated int64
if err = rows.Scan(&value.ID, &value.Slug, &value.Name, &value.Status, &value.Personal, &value.Revision, &created, &updated); err != nil {
return organizations.DirectoryPage{}, err
}
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
page.Organizations = append(page.Organizations, value)
}
if err = rows.Err(); err != nil {
return organizations.DirectoryPage{}, err
}
if len(page.Organizations) > limit {
page.Organizations = page.Organizations[:limit]
page.NextID = page.Organizations[limit-1].ID
}
return page, nil
}
func directoryQuery(search, after string, limit int) (string, int, bool) {
if !text(search, 128, true) || after != "" && !opaqueID(after) || limit < 0 || limit > 200 {
return "", 0, false
}
if limit == 0 {
limit = 50
}
// Wildcards and the escape character are literal user text, never operators.
pattern := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.TrimSpace(search)) + "%"
return pattern, limit, true
}
+146
View File
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"gamertan.com/web/auth"
"gamertan.com/web/organizations"
)
func TestInstanceDirectoriesAreBoundedCredentialFreeAndIndependentOfMembership(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "directory.sqlite"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
ctx := t.Context()
for index := 0; index < 205; index++ {
id := fmt.Sprintf("record-%03d", index)
status := []string{"active", "suspended", "disabled"}[index%3]
_, err = store.db.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,1,1)`, id, id, id, id+"@example.test", id+"@example.test", "Person "+id, status, index%2, index%5 == 0)
if err != nil {
t.Fatal(err)
}
_, err = store.db.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,status,revision,created_at,updated_at) VALUES(?,?,?,?,?,1,1,1)`, id, id, "Business "+id, index%2, []string{"active", "archived"}[index%2])
if err != nil {
t.Fatal(err)
}
}
users, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{})
if err != nil || len(users.Users) != 50 || users.NextID != "record-049" {
t.Fatalf("default users: %d %q %v", len(users.Users), users.NextID, err)
}
if !users.Users[0].RegistrationPending || users.Users[1].Status != "suspended" || !users.Users[1].PasswordChangeRequired || users.Users[2].Status != "disabled" {
t.Fatal("administrative account states were hidden")
}
encoded, _ := json.Marshal(users)
for _, secret := range []string{"password_hash", "Session", "Digest", "Credential", "Recovery"} {
if strings.Contains(string(encoded), secret) {
t.Fatalf("directory leaked credential field %s", secret)
}
}
for _, size := range []int{1, 50, 200} {
userAfter, orgAfter, count := "", "", 0
for {
users, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{AfterID: userAfter, Limit: size})
if err != nil {
t.Fatal(err)
}
orgs, err := store.OrganizationDirectory(ctx, organizations.DirectoryQuery{AfterID: orgAfter, Limit: size})
if err != nil {
t.Fatal(err)
}
if len(users.Users) != len(orgs.Organizations) || len(users.Users) > size {
t.Fatal("invalid page bound")
}
for index, user := range users.Users {
want := fmt.Sprintf("record-%03d", count)
if user.ID != want || orgs.Organizations[index].ID != want {
t.Fatalf("pagination skipped/duplicated %s", want)
}
count++
}
if users.NextID == "" || orgs.NextID == "" {
if users.NextID != orgs.NextID || count != 205 {
t.Fatalf("early end: %d", count)
}
break
}
userAfter, orgAfter = users.NextID, orgs.NextID
}
}
orgs, err := store.OrganizationDirectory(ctx, organizations.DirectoryQuery{Limit: 2})
if err != nil || orgs.Organizations[0].Personal || !orgs.Organizations[1].Personal || orgs.Organizations[1].Status != "archived" {
t.Fatal("personal/archived organizations omitted")
}
// A display-name change cannot move a record behind a stable-ID cursor.
if _, err = store.db.Exec(`UPDATE gwf_users SET display_name='AAA' WHERE id='record-050'`); err != nil {
t.Fatal(err)
}
next, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{AfterID: "record-049", Limit: 1})
if err != nil || next.Users[0].ID != "record-050" {
t.Fatal("name change disturbed cursor")
}
}
func TestInstanceDirectoryLiteralSearchValidationAndCancellation(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "directory.sqlite"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
_, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES('person-001','Alice','alice','Alice@example.test','alice@example.test','Élodie 50%_\ works','active',1,1)`)
if err != nil {
t.Fatal(err)
}
_, err = store.db.Exec(`INSERT INTO gwf_organizations(id,slug,name,personal,status,revision,created_at,updated_at) VALUES('company-001','alice-company','Élodie 50%_\ works',0,'active',1,1,1)`)
if err != nil {
t.Fatal(err)
}
for _, search := range []string{"", " ALICE ", "example.test", "person-001", "Élodie", `50%_\`} {
page, err := store.UserDirectory(t.Context(), auth.UserDirectoryQuery{Search: search})
if err != nil || len(page.Users) != 1 || page.NextID != "" {
t.Errorf("user literal search %q: %#v %v", search, page, err)
}
}
for _, search := range []string{"", "ALICE", "company-001", "Élodie", `50%_\`} {
page, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: search})
if err != nil || len(page.Organizations) != 1 || page.NextID != "" {
t.Errorf("organization literal search %q: %#v %v", search, page, err)
}
}
for _, search := range []string{"absent", "%' OR 1=1 --", "%_%", "\\_%"} {
users, err := store.UserDirectory(t.Context(), auth.UserDirectoryQuery{Search: search})
if err != nil || users.Users == nil || len(users.Users) != 0 {
t.Errorf("nonliteral user search %q", search)
}
orgs, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: search})
if err != nil || orgs.Organizations == nil || len(orgs.Organizations) != 0 {
t.Errorf("nonliteral org search %q", search)
}
}
for _, query := range []auth.UserDirectoryQuery{{Search: strings.Repeat("a", 129)}, {Search: "bad\x00value"}, {Search: "bad\nvalue"}, {Search: "\xff"}, {AfterID: "bad/id"}, {AfterID: strings.Repeat("a", 129)}, {Limit: -1}, {Limit: 201}} {
if _, err := store.UserDirectory(t.Context(), query); !errors.Is(err, auth.ErrDirectoryQuery) {
t.Errorf("invalid user query accepted: %#v %v", query, err)
}
if _, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: query.Search, AfterID: query.AfterID, Limit: query.Limit}); !errors.Is(err, organizations.ErrDirectoryQuery) {
t.Errorf("invalid org query accepted: %#v %v", query, err)
}
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err = store.UserDirectory(ctx, auth.UserDirectoryQuery{}); !errors.Is(err, context.Canceled) {
t.Fatalf("user cancellation: %v", err)
}
if _, err = store.OrganizationDirectory(ctx, organizations.DirectoryQuery{}); !errors.Is(err, context.Canceled) {
t.Fatalf("organization cancellation: %v", err)
}
}
+415
View File
@@ -0,0 +1,415 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"strings"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authmail"
"gamertan.com/web/mail"
"gamertan.com/web/mailsqlite"
"modernc.org/sqlite"
)
// MailRepository is optional. Construction opens no new connection and performs
// no migration. The outbox shares this store's DB so account/audit/mail commits
// are atomic. Applications must check RequireMailSchema before enabling routes.
type MailRepository struct {
store *Store
queue *mailsqlite.Queue
now func() time.Time
}
func (store *Store) AccountMail(options mailsqlite.Options) (*MailRepository, *mailsqlite.Queue, error) {
if options.Now == nil {
options.Now = time.Now
}
queue, err := mailsqlite.New(store.db, options)
if err != nil {
return nil, nil, err
}
return &MailRepository{store: store, queue: queue, now: options.Now}, queue, nil
}
const MailSchemaVersion = 1
func (store *Store) RequireMailSchema(ctx context.Context) error {
var version int
if err := store.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM gwf_account_mail_migrations`).Scan(&version); err != nil {
return errors.New("authsqlite: explicit account mail migration required")
}
if version != MailSchemaVersion {
return errors.New("authsqlite: incompatible account mail schema")
}
return nil
}
// MigrateMail is an explicit operator migration, separate from schema 11. It
// creates no verified identities for existing users and touches no commerce data.
func (store *Store) MigrateMail(ctx context.Context) error {
if err := store.RequireCurrentSchema(ctx); err != nil {
return err
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS gwf_account_mail_migrations(version INTEGER PRIMARY KEY,applied_at INTEGER NOT NULL)`); err != nil {
return err
}
var version int
if err = tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM gwf_account_mail_migrations`).Scan(&version); err != nil {
return err
}
if version > MailSchemaVersion {
return errors.New("authsqlite: newer account mail schema")
}
if version == MailSchemaVersion {
return tx.Commit()
}
if err = mailsqlite.CreateSchema(ctx, tx); err != nil {
return err
}
for _, statement := range []string{
`CREATE TABLE gwf_verified_emails(user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE,email_normalized TEXT NOT NULL,verified_at INTEGER NOT NULL)`,
`CREATE TABLE gwf_account_mail_requests(id TEXT NOT NULL UNIQUE,user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE,purpose TEXT NOT NULL CHECK(purpose IN ('verify','change','reset')),email TEXT NOT NULL,new_email TEXT NOT NULL,profile_revision INTEGER NOT NULL,credential_digest BLOB NOT NULL CHECK(length(credential_digest)=32),session_digest BLOB NOT NULL CHECK(length(session_digest)=32),new_digest BLOB NOT NULL UNIQUE CHECK(length(new_digest)=32),old_digest BLOB UNIQUE,new_confirmed INTEGER NOT NULL DEFAULT 0 CHECK(new_confirmed IN (0,1)),old_confirmed INTEGER NOT NULL DEFAULT 0 CHECK(old_confirmed IN (0,1)),created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL CHECK(expires_at>created_at),PRIMARY KEY(user_id,purpose),CHECK(old_digest IS NULL OR length(old_digest)=32))`,
`CREATE INDEX gwf_account_mail_requests_expiry ON gwf_account_mail_requests(expires_at)`,
`CREATE TABLE gwf_account_mail_limits(user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE,window_started INTEGER NOT NULL,request_count INTEGER NOT NULL,last_requested INTEGER NOT NULL)`,
} {
if _, err = tx.ExecContext(ctx, statement); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_migrations(version,applied_at) VALUES(?,?)`, MailSchemaVersion, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit()
}
const mailSubjectSelect = `SELECT u.id,u.email,c.password_hash,u.profile_revision,EXISTS(SELECT 1 FROM gwf_verified_emails v WHERE v.user_id=u.id AND v.email_normalized=u.email_normalized)
FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.status='active' AND u.registration_pending=0`
func scanMailSubject(row interface{ Scan(...any) error }) (authmail.Subject, error) {
var subject authmail.Subject
err := row.Scan(&subject.UserID, &subject.Email, &subject.PasswordHash, &subject.Revision, &subject.Verified)
if errors.Is(err, sql.ErrNoRows) {
return subject, authmail.ErrUnavailable
}
return subject, err
}
func (repo *MailRepository) OwnSubject(ctx context.Context, session [32]byte, now time.Time) (authmail.Subject, error) {
if zeroDigest(session) || now.IsZero() {
return authmail.Subject{}, authmail.ErrUnavailable
}
return scanMailSubject(repo.store.db.QueryRowContext(ctx, mailSubjectSelect+` AND u.password_change_required=0 AND EXISTS(SELECT 1 FROM gwf_auth_sessions s WHERE s.user_id=u.id AND s.token_hash=? AND s.expires_at>?)`, session[:], now.Unix()))
}
func (repo *MailRepository) ResetSubject(ctx context.Context, email string, now time.Time) (authmail.Subject, error) {
value, err := authmail.NormalizeEmail(email)
if err != nil || value != email || now.IsZero() {
return authmail.Subject{}, authmail.ErrUnavailable
}
return scanMailSubject(repo.store.db.QueryRowContext(ctx, mailSubjectSelect+` AND u.email_normalized=? AND EXISTS(SELECT 1 FROM gwf_verified_emails v WHERE v.user_id=u.id AND v.email_normalized=u.email_normalized)`, email))
}
func mailAuditValid(audit auth.AuditEvent, request authmail.Request, suffix string) bool {
actor := request.UserID
if request.Purpose == authmail.Reset {
actor = ""
}
return validAuditEvent(audit) && audit.ActorUserID == actor && audit.Action == "auth.mail."+string(request.Purpose)+suffix && audit.ResourceType == "user" && audit.ResourceID == request.UserID
}
func mailRequestValid(request authmail.Request) bool {
email, err := authmail.NormalizeEmail(request.Email)
if err != nil || email != request.Email || !opaqueID(request.ID) || !opaqueID(request.UserID) || request.Revision < 1 || zeroDigest(request.NewDigest) || zeroDigest(request.CredentialDigest) || request.CreatedAt.IsZero() || request.ExpiresAt.Sub(request.CreatedAt) != authmail.Lifetime {
return false
}
if request.Purpose == authmail.Change {
value, err := authmail.NormalizeEmail(request.NewEmail)
return err == nil && value == request.NewEmail && value != email && !zeroDigest(request.OldDigest) && request.OldDigest != request.NewDigest && !zeroDigest(request.SessionDigest)
}
return request.NewEmail == "" && zeroDigest(request.OldDigest) && (request.Purpose == authmail.Verify && !zeroDigest(request.SessionDigest) || request.Purpose == authmail.Reset && zeroDigest(request.SessionDigest))
}
func mailSubjectMatches(subject authmail.Subject, request authmail.Request) bool {
return subject.UserID == request.UserID && normalize(subject.Email) == request.Email && subject.Revision == request.Revision && sha256.Sum256([]byte(subject.PasswordHash)) == request.CredentialDigest && (request.Purpose != authmail.Reset || subject.Verified)
}
func mailSessionCurrent(ctx context.Context, tx *sql.Tx, request authmail.Request, now time.Time) error {
if request.Purpose == authmail.Reset {
return nil
}
var valid int
err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.user_id=? AND s.token_hash=? AND s.expires_at>? AND u.password_change_required=0`, request.UserID, request.SessionDigest[:], now.Unix()).Scan(&valid)
if err != nil {
return err
}
if valid != 1 {
return authmail.ErrUnavailable
}
return nil
}
func (repo *MailRepository) Issue(ctx context.Context, request authmail.Request, messages []mail.Message, audit auth.AuditEvent) error {
if !mailRequestValid(request) || !mailAuditValid(audit, request, ".request") || !audit.CreatedAt.Equal(request.CreatedAt) {
return authmail.ErrInvalid
}
target := request.Email
if request.Purpose == authmail.Change {
target = request.NewEmail
}
if len(messages) != 1 && request.Purpose != authmail.Change || request.Purpose == authmail.Change && len(messages) != 2 {
return authmail.ErrInvalid
}
if messages[0].To != target || messages[0].Validate() != nil || !messages[0].CreatedAt.Equal(request.CreatedAt) {
return authmail.ErrInvalid
}
if len(messages) == 2 && (messages[1].To != request.Email || messages[1].Validate() != nil || messages[0].ID == messages[1].ID || !messages[1].CreatedAt.Equal(request.CreatedAt)) {
return authmail.ErrInvalid
}
tx, err := repo.store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Acquire the writer lock before all identity, uniqueness and rate checks.
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET updated_at=updated_at WHERE id=?`, request.UserID); err != nil {
return err
}
current := repo.now().UTC()
if !request.ExpiresAt.After(current) || request.CreatedAt.After(current.Add(time.Minute)) || request.CreatedAt.Before(current.Add(-time.Minute)) {
return authmail.ErrUnavailable
}
subject, err := scanMailSubject(tx.QueryRowContext(ctx, mailSubjectSelect+` AND u.id=?`, request.UserID))
if err != nil {
return err
}
if !mailSubjectMatches(subject, request) {
return authmail.ErrUnavailable
}
if err = mailSessionCurrent(ctx, tx, request, current); err != nil {
return err
}
if request.Purpose == authmail.Verify && subject.Verified {
return authmail.ErrUnavailable
}
if request.Purpose == authmail.Change {
var collision int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users WHERE email_normalized=?`, request.NewEmail).Scan(&collision); err != nil {
return err
}
if collision != 0 {
return authmail.ErrAddressUnavailable
}
}
var start, last int64
var count int
err = tx.QueryRowContext(ctx, `SELECT window_started,request_count,last_requested FROM gwf_account_mail_limits WHERE user_id=?`, request.UserID).Scan(&start, &count, &last)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
now := current.Unix()
if last > now-60 || start > now-3600 && count >= 5 {
return authmail.ErrLimited
}
if start <= now-3600 {
start, count = now, 0
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_limits(user_id,window_started,request_count,last_requested) VALUES(?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET window_started=excluded.window_started,request_count=excluded.request_count,last_requested=excluded.last_requested`, request.UserID, start, count+1, now); err != nil {
return err
}
var old []byte
if request.Purpose == authmail.Change {
old = request.OldDigest[:]
}
// Both token columns share one logical namespace. Detect even an entropy
// failure that collides with the other confirmation leg before inserting.
var tokenCollision int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_account_mail_requests WHERE new_digest IN (?,?) OR old_digest IN (?,?)`, request.NewDigest[:], old, request.NewDigest[:], old).Scan(&tokenCollision); err != nil {
return err
}
if tokenCollision != 0 {
return authmail.ErrInvalid
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_requests(id,user_id,purpose,email,new_email,profile_revision,credential_digest,session_digest,new_digest,old_digest,created_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(user_id,purpose) DO UPDATE SET id=excluded.id,email=excluded.email,new_email=excluded.new_email,profile_revision=excluded.profile_revision,credential_digest=excluded.credential_digest,session_digest=excluded.session_digest,new_digest=excluded.new_digest,old_digest=excluded.old_digest,new_confirmed=0,old_confirmed=0,created_at=excluded.created_at,expires_at=excluded.expires_at`, request.ID, request.UserID, request.Purpose, request.Email, request.NewEmail, request.Revision, request.CredentialDigest[:], request.SessionDigest[:], request.NewDigest[:], old, request.CreatedAt.Unix(), request.ExpiresAt.Unix()); err != nil {
return err
}
for _, message := range messages {
if err = repo.queue.EnqueueTx(ctx, tx, message, request.ExpiresAt); err != nil {
return err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
type mailQuerier interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func pendingMail(ctx context.Context, query mailQuerier, digest [32]byte, now time.Time) (authmail.Pending, error) {
var pending authmail.Pending
if zeroDigest(digest) || now.IsZero() {
return pending, authmail.ErrUnavailable
}
request := &pending.Request
var credential, session, next, old []byte
var created, expires int64
err := query.QueryRowContext(ctx, `SELECT id,user_id,purpose,email,new_email,profile_revision,credential_digest,session_digest,new_digest,old_digest,created_at,expires_at FROM gwf_account_mail_requests WHERE expires_at>? AND ((new_digest=? AND new_confirmed=0) OR (old_digest=? AND old_confirmed=0))`, now.Unix(), digest[:], digest[:]).Scan(&request.ID, &request.UserID, &request.Purpose, &request.Email, &request.NewEmail, &request.Revision, &credential, &session, &next, &old, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return pending, authmail.ErrUnavailable
}
if err != nil {
return pending, err
}
copy(request.CredentialDigest[:], credential)
copy(request.SessionDigest[:], session)
copy(request.NewDigest[:], next)
copy(request.OldDigest[:], old)
request.CreatedAt, request.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
if !mailRequestValid(*request) {
return pending, authmail.ErrUnavailable
}
pending.OldToken = digest == request.OldDigest
pending.Subject, err = scanMailSubject(query.QueryRowContext(ctx, mailSubjectSelect+` AND u.id=?`, request.UserID))
if err != nil {
return pending, err
}
if !mailSubjectMatches(pending.Subject, *request) {
return pending, authmail.ErrUnavailable
}
if request.Purpose != authmail.Reset {
var valid int
err = query.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.user_id=? AND s.token_hash=? AND s.expires_at>? AND u.password_change_required=0`, request.UserID, request.SessionDigest[:], now.Unix()).Scan(&valid)
if err != nil {
return pending, err
}
if valid != 1 {
return pending, authmail.ErrUnavailable
}
}
return pending, nil
}
func (repo *MailRepository) Pending(ctx context.Context, digest [32]byte, now time.Time) (authmail.Pending, error) {
return pendingMail(ctx, repo.store.db, digest, now)
}
func (repo *MailRepository) Complete(ctx context.Context, digest [32]byte, requestID, newHash string, notices []mail.Message, audit auth.AuditEvent) (bool, error) {
if zeroDigest(digest) || !opaqueID(requestID) || !validAuditEvent(audit) {
return false, authmail.ErrInvalid
}
tx, err := repo.store.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `UPDATE gwf_account_mail_requests SET expires_at=expires_at WHERE id=?`, requestID); err != nil {
return false, err
}
// Re-read time after acquiring the writer lock, not before password hashing
// or a database wait. A just-expired token must not complete a mutation.
audit.CreatedAt = repo.now().UTC().Truncate(time.Second)
pending, err := pendingMail(ctx, tx, digest, audit.CreatedAt)
if err != nil {
return false, err
}
request := pending.Request
if request.ID != requestID || !mailAuditValid(audit, request, ".confirm") {
return false, authmail.ErrUnavailable
}
if request.Purpose == authmail.Reset {
if !strings.HasPrefix(newHash, "$argon2id$") || len(newHash) > 1024 || newHash == pending.Subject.PasswordHash || len(notices) != 1 || notices[0].To != request.Email {
return false, authmail.ErrInvalid
}
} else if newHash != "" || request.Purpose == authmail.Verify && len(notices) != 0 || request.Purpose == authmail.Change && (len(notices) != 2 || notices[0].To != request.Email || notices[1].To != request.NewEmail) {
return false, authmail.ErrInvalid
}
for _, message := range notices {
if message.Validate() != nil {
return false, authmail.ErrInvalid
}
}
column := "new_confirmed"
if pending.OldToken {
column = "old_confirmed"
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_account_mail_requests SET `+column+`=1 WHERE id=?`, requestID); err != nil {
return false, err
}
if request.Purpose == authmail.Change {
var ready bool
if err = tx.QueryRowContext(ctx, `SELECT new_confirmed=1 AND old_confirmed=1 FROM gwf_account_mail_requests WHERE id=?`, requestID).Scan(&ready); err != nil {
return false, err
}
if !ready {
if err = appendAudit(ctx, tx, audit); err != nil {
return false, err
}
return false, tx.Commit()
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET email=?,email_normalized=?,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?) WHERE id=? AND profile_revision<9223372036854775807`, request.NewEmail, request.NewEmail, audit.CreatedAt.Unix(), request.UserID)
if err != nil {
var constraint *sqlite.Error
if errors.As(err, &constraint) && constraint.Code() == 2067 {
return false, authmail.ErrAddressUnavailable
}
return false, err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return false, authmail.ErrUnavailable
}
// Invitations issued to the previous identity do not migrate to another
// mailbox/account. Existing memberships remain bound to immutable user ID.
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE email_normalized=? AND used_at IS NULL AND revoked_at IS NULL`, audit.CreatedAt.Unix(), request.Email); err != nil {
return false, err
}
}
if request.Purpose != authmail.Reset {
email := request.Email
if request.Purpose == authmail.Change {
email = request.NewEmail
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_verified_emails(user_id,email_normalized,verified_at) VALUES(?,?,?) ON CONFLICT(user_id) DO UPDATE SET email_normalized=excluded.email_normalized,verified_at=excluded.verified_at`, request.UserID, email, audit.CreatedAt.Unix()); err != nil {
return false, err
}
} else {
if _, err = tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=?`, newHash, audit.CreatedAt.Unix(), request.UserID); err != nil {
return false, err
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=MAX(updated_at,?) WHERE id=?`, audit.CreatedAt.Unix(), request.UserID); err != nil {
return false, err
}
}
if request.Purpose != authmail.Verify {
// Keep enrolled passkeys and recovery-code digests. Revoke only sessions,
// in-flight ceremonies, enrollment/recovery grants and pending mail links.
for _, table := range []string{"gwf_auth_sessions", "gwf_passkey_ceremonies", "gwf_passkey_enrollment_tokens", "gwf_recovery_grants", "gwf_assisted_recovery_grants", "gwf_account_mail_requests"} {
if _, err = tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE user_id=?`, request.UserID); err != nil {
return false, err
}
}
} else if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_account_mail_requests WHERE id=?`, requestID); err != nil {
return false, err
}
for _, message := range notices {
if err = repo.queue.EnqueueTx(ctx, tx, message, audit.CreatedAt.Add(24*time.Hour)); err != nil {
return false, err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return false, err
}
return true, tx.Commit()
}
+592
View File
@@ -0,0 +1,592 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"net/url"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authmail"
"gamertan.com/web/mail"
"gamertan.com/web/mailsqlite"
)
const mailPassword = "correct horse battery staple"
var accountMailHash = sync.OnceValues(func() (string, error) { return auth.HashPassword(mailPassword) })
type mailClock struct{ unix atomic.Int64 }
func (clock *mailClock) now() time.Time { return time.Unix(clock.unix.Load(), 0).UTC() }
func (clock *mailClock) advance(duration time.Duration) {
clock.unix.Add(int64(duration / time.Second))
}
type mailFixture struct {
store *Store
repo *MailRepository
queue *mailsqlite.Queue
service *authmail.Service
clock *mailClock
user auth.User
session auth.Session
path string
}
func mailConfig(clock *mailClock) authmail.Config {
return authmail.Config{Origin: "https://accounts.example.test", SiteName: "Example Site", ConfirmPath: "/account/email/confirm/", ResetPath: "/reset-password/", SecurityPath: "/account/security/", Now: clock.now}
}
func accountMailFixture(t *testing.T, capacity int) mailFixture {
t.Helper()
clock := &mailClock{}
clock.unix.Store(time.Date(2026, 9, 11, 8, 0, 0, 0, time.UTC).Unix())
path := filepath.Join(t.TempDir(), "identity.sqlite")
store, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
if err = store.RequireMailSchema(t.Context()); err == nil {
t.Fatal("mail schema silently installed")
}
if err = store.MigrateMail(t.Context()); err != nil {
t.Fatal(err)
}
if err = store.MigrateMail(t.Context()); err != nil {
t.Fatal(err)
}
if err = store.RequireMailSchema(t.Context()); err != nil {
t.Fatal(err)
}
user := auth.User{ID: "mail_user_fixture", Username: "mail.reader", Email: "reader@example.test", DisplayName: "Reader", Status: "active", CreatedAt: clock.now(), UpdatedAt: clock.now()}
hash, err := accountMailHash()
if err != nil {
t.Fatal(err)
}
if err = store.CreateUser(t.Context(), user, hash); err != nil {
t.Fatal(err)
}
session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-mail-session")), CreatedAt: clock.now(), ExpiresAt: clock.now().Add(24 * time.Hour), LastSeenAt: clock.now()}
if err = store.CreateSession(t.Context(), session); err != nil {
t.Fatal(err)
}
repo, queue, err := store.AccountMail(mailsqlite.Options{EncryptionKey: bytes.Repeat([]byte{31}, 32), MaxPending: capacity, Now: clock.now})
if err != nil {
t.Fatal(err)
}
service, err := authmail.New(repo, mailConfig(clock))
if err != nil {
t.Fatal(err)
}
return mailFixture{store, repo, queue, service, clock, user, session, path}
}
type collectMail struct{ messages []mail.Message }
func (collector *collectMail) Send(_ context.Context, message mail.Message) error {
collector.messages = append(collector.messages, message)
return nil
}
func (fixture mailFixture) drain(t *testing.T) []mail.Message {
t.Helper()
collector := &collectMail{}
for range 10 {
record, work, err := fixture.queue.ProcessOne(t.Context(), collector)
if err != nil {
t.Fatal(err)
}
if !work {
return collector.messages
}
if record.State != "accepted" {
t.Fatalf("outbox state=%s stage=%s", record.State, record.FailureStage)
}
}
t.Fatal("unbounded fixture queue")
return nil
}
func mailToken(t *testing.T, message mail.Message) string {
t.Helper()
for _, part := range strings.Fields(message.Text) {
parsed, err := url.Parse(part)
if err == nil && parsed.Scheme == "https" && parsed.Query().Get("token") != "" {
if parsed.Host != "accounts.example.test" {
t.Fatal("untrusted action origin")
}
return parsed.Query().Get("token")
}
}
t.Fatal("no action token in fixture mail")
return ""
}
func (fixture mailFixture) verify(t *testing.T) {
t.Helper()
if err := fixture.service.RequestVerification(t.Context(), fixture.session.Digest); err != nil {
t.Fatal(err)
}
messages := fixture.drain(t)
if len(messages) != 1 {
t.Fatalf("verification messages=%d", len(messages))
}
if complete, err := fixture.service.Confirm(t.Context(), mailToken(t, messages[0])); err != nil || !complete {
t.Fatalf("verify: %v %v", complete, err)
}
fixture.clock.advance(time.Minute)
}
func TestMailVerificationResetAndPreservedFactors(t *testing.T) {
f := accountMailFixture(t, 0)
if verified, err := f.service.Status(t.Context(), f.session.Digest); err != nil || verified {
t.Fatalf("legacy auto-verified: %v %v", verified, err)
}
for _, email := range []string{f.user.Email, "unknown@example.test", "not an address"} {
if err := f.service.RequestReset(t.Context(), email); err != nil {
t.Fatal(err)
}
}
if got := f.drain(t); len(got) != 0 {
t.Fatal("unverified/unknown address received reset")
}
if err := f.service.RequestVerification(t.Context(), f.session.Digest); err != nil {
t.Fatal(err)
}
messages := f.drain(t)
if len(messages) != 1 || messages[0].To != f.user.Email {
t.Fatal("verification recipient")
}
token := mailToken(t, messages[0])
for range 2 {
if purpose, err := f.service.Inspect(t.Context(), token); err != nil || purpose != authmail.Verify {
t.Fatalf("inspect: %s %v", purpose, err)
}
}
if verified, _ := f.service.Status(t.Context(), f.session.Digest); verified {
t.Fatal("read-only inspection mutated identity")
}
if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("verify token used for reset: %v", err)
}
if completed, err := f.service.Confirm(t.Context(), token); err != nil || !completed {
t.Fatalf("confirm: %v %v", completed, err)
}
if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("token replay: %v", err)
}
if verified, err := f.service.Status(t.Context(), f.session.Digest); err != nil || !verified {
t.Fatalf("not verified: %v %v", verified, err)
}
for _, statement := range []string{
`INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at) VALUES(X'010203','mail_user_fixture','Fixture',X'7B7D',1)`,
`INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at) VALUES('mail_user_fixture',X'1234',1)`,
`INSERT INTO gwf_recovery_grants(token_hash,user_id,created_at,expires_at) VALUES(X'1234','mail_user_fixture',1,9999999999)`,
`INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(X'1234','mail_user_fixture',1,9999999999)`,
} {
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
}
f.clock.advance(time.Minute)
if err := f.service.RequestReset(t.Context(), strings.ToUpper(f.user.Email)); err != nil {
t.Fatal(err)
}
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal("rate limit disclosed account", err)
}
messages = f.drain(t)
if len(messages) != 1 {
t.Fatalf("reset requests=%d", len(messages))
}
token = mailToken(t, messages[0])
if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("reset token confirmed: %v", err)
}
if err := f.service.ResetPassword(t.Context(), token, mailPassword); !errors.Is(err, auth.ErrPasswordUnchanged) {
t.Fatalf("password reuse: %v", err)
}
if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil {
t.Fatal(err)
}
user, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.ID != f.user.ID || user.Email != f.user.Email || !auth.VerifyPassword(hash, "a different safe password") || auth.VerifyPassword(hash, mailPassword) {
t.Fatal("incorrect reset result", err)
}
if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived: %v", err)
}
for table, want := range map[string]int{"gwf_passkey_credentials": 1, "gwf_recovery_codes": 1, "gwf_recovery_grants": 0, "gwf_passkey_enrollment_tokens": 0, "gwf_account_mail_requests": 0} {
var count int
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE user_id=?`, f.user.ID).Scan(&count); err != nil || count != want {
t.Fatalf("%s count=%d want=%d err=%v", table, count, want, err)
}
}
if err = f.service.ResetPassword(t.Context(), token, "third different safe password"); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("reset replay: %v", err)
}
messages = f.drain(t)
if len(messages) != 1 || !strings.Contains(messages[0].Subject, "password was changed") || strings.Contains(messages[0].Text, "token=") {
t.Fatal("password-change notice missing or carries token")
}
}
func TestMailAddressChangeBothMailboxesAndStableOwnership(t *testing.T) {
for _, oldFirst := range []bool{false, true} {
t.Run(map[bool]string{false: "new-first", true: "old-first"}[oldFirst], func(t *testing.T) {
f := accountMailFixture(t, 0)
if err := f.service.RequestChange(t.Context(), f.session.Digest, "wrong password", "new@example.test"); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("unauthenticated change: %v", err)
}
if err := f.service.RequestChange(t.Context(), f.session.Digest, mailPassword, "NEW@example.test"); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`CREATE TABLE preserved_purchase(id TEXT PRIMARY KEY,user_id TEXT,old_email TEXT)`,
`INSERT INTO preserved_purchase VALUES('order_fixture','mail_user_fixture','reader@example.test')`,
`INSERT INTO gwf_organizations(id,slug,name,personal,created_at,updated_at) VALUES('family_fixture','family-fixture','Family',0,1,1)`,
`INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES('family_fixture','mail_user_fixture','active',1)`,
`INSERT INTO gwf_organization_invitations(token_hash,id,organization_id,email_normalized,invited_by_user_id,created_at,expires_at) VALUES(X'0101','old_invitation','family_fixture','reader@example.test','mail_user_fixture',1,9999999999)`,
`INSERT INTO gwf_organization_invitations(token_hash,id,organization_id,email_normalized,invited_by_user_id,created_at,expires_at) VALUES(X'0202','new_invitation','family_fixture','new@example.test','mail_user_fixture',1,9999999999)`,
} {
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
}
messages := f.drain(t)
if len(messages) != 2 {
t.Fatalf("change messages=%d", len(messages))
}
var oldToken, newToken string
for _, message := range messages {
if message.To == f.user.Email {
oldToken = mailToken(t, message)
} else if message.To == "new@example.test" {
newToken = mailToken(t, message)
} else {
t.Fatal("wrong mailbox")
}
}
first, second := newToken, oldToken
if oldFirst {
first, second = oldToken, newToken
}
if done, err := f.service.Confirm(t.Context(), first); err != nil || done {
t.Fatalf("first confirmation: %v %v", done, err)
}
user, _, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.Email != f.user.Email {
t.Fatal("address changed with one mailbox")
}
if got := f.drain(t); len(got) != 0 {
t.Fatal("premature change notice")
}
if _, err = f.service.Confirm(t.Context(), first); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("first-leg replay: %v", err)
}
if done, err := f.service.Confirm(t.Context(), second); err != nil || !done {
t.Fatalf("second confirmation: %v %v", done, err)
}
user, _, err = f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.Email != "new@example.test" || user.ID != f.user.ID {
t.Fatal("canonical identity changed incorrectly")
}
if _, _, err = f.store.CredentialByIdentifier(t.Context(), f.user.Email); !errors.Is(err, auth.ErrUserNotFound) {
t.Fatalf("old login address survived: %v", err)
}
if subject, err := f.repo.ResetSubject(t.Context(), "new@example.test", f.clock.now()); err != nil || !subject.Verified {
t.Fatal("new mailbox not verified", err)
}
var value string
if err = f.store.db.QueryRow(`SELECT user_id||':'||old_email FROM preserved_purchase`).Scan(&value); err != nil || value != f.user.ID+":"+f.user.Email {
t.Fatal("historical purchase changed")
}
var count int
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, f.user.ID).Scan(&count); err != nil || count != 1 {
t.Fatal("membership changed")
}
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_invitations WHERE id='old_invitation' AND revoked_at IS NOT NULL`).Scan(&count); err != nil || count != 1 {
t.Fatal("old invitation retained")
}
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_invitations WHERE id='new_invitation' AND revoked_at IS NULL AND used_at IS NULL`).Scan(&count); err != nil || count != 1 {
t.Fatal("new invitation granted or rewritten")
}
if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatal("change did not sign out sessions")
}
if got := f.drain(t); len(got) != 2 {
t.Fatalf("change notices=%d", len(got))
}
})
}
}
func TestMailTokenInvalidation(t *testing.T) {
for _, test := range []struct {
name, statement string
advance time.Duration
}{
{"expiry", "", authmail.Lifetime},
{"password-changed", `UPDATE gwf_password_credentials SET password_hash='new-hash'`, 0},
{"email-changed", `UPDATE gwf_users SET email='elsewhere@example.test',email_normalized='elsewhere@example.test'`, 0},
{"profile-revision", `UPDATE gwf_users SET profile_revision=profile_revision+1`, 0},
{"suspended", `UPDATE gwf_users SET status='suspended'`, 0},
{"pending", `UPDATE gwf_users SET registration_pending=1`, 0},
{"session-revoked", `DELETE FROM gwf_auth_sessions`, 0},
{"forced-password-change", `UPDATE gwf_users SET password_change_required=1`, 0},
} {
t.Run(test.name, func(t *testing.T) {
f := accountMailFixture(t, 0)
if err := f.service.RequestVerification(t.Context(), f.session.Digest); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
if test.statement != "" {
if _, err := f.store.db.Exec(test.statement); err != nil {
t.Fatal(err)
}
}
f.clock.advance(test.advance)
if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("invalidated token accepted: %v", err)
}
var count int
if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_verified_emails`).Scan(&count); err != nil || count != 0 {
t.Fatal("invalidated verification wrote state")
}
})
}
}
func TestMailAuditAndOutboxRollback(t *testing.T) {
f := accountMailFixture(t, 1)
if _, err := f.store.db.Exec(`CREATE TRIGGER reject_mail_audit BEFORE INSERT ON gwf_audit_events WHEN NEW.action LIKE 'auth.mail.%' BEGIN SELECT RAISE(ABORT,'fixture audit failure'); END`); err != nil {
t.Fatal(err)
}
if err := f.service.RequestVerification(t.Context(), f.session.Digest); err == nil {
t.Fatal("audit failure ignored")
}
for _, table := range []string{"gwf_account_mail_requests", "gwf_account_mail_limits", "gwf_mail_outbox"} {
var count int
if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil || count != 0 {
t.Fatalf("%s not rolled back", table)
}
}
if _, err := f.store.db.Exec(`DROP TRIGGER reject_mail_audit`); err != nil {
t.Fatal(err)
}
f.verify(t)
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
if err := f.queue.Enqueue(t.Context(), mail.Message{ID: "mail_capacity_fixture", To: "other@example.test", Subject: "Fixture", Text: "Unrelated message", CreatedAt: f.clock.now()}, f.clock.now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, mailsqlite.ErrFull) {
t.Fatalf("outbox failure: %v", err)
}
_, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || !auth.VerifyPassword(hash, mailPassword) {
t.Fatal("password changed without committed notification")
}
if _, err = f.service.Inspect(t.Context(), token); err != nil {
t.Fatal("token lost on rollback", err)
}
if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); err != nil {
t.Fatal("session lost on rollback", err)
}
f.drain(t)
if err = f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil {
t.Fatal("retry after queue recovery", err)
}
}
func TestMailConcurrentConsumeAndRateLimit(t *testing.T) {
f := accountMailFixture(t, 0)
f.verify(t)
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
var group sync.WaitGroup
var accepted, rejected atomic.Int32
for range 2 {
group.Go(func() {
err := f.service.ResetPassword(context.Background(), token, "a different safe password")
if err == nil {
accepted.Add(1)
} else if errors.Is(err, authmail.ErrUnavailable) {
rejected.Add(1)
} else {
t.Errorf("concurrent consume: %v", err)
}
})
}
group.Wait()
if accepted.Load() != 1 || rejected.Load() != 1 {
t.Fatalf("accepted=%d rejected=%d", accepted.Load(), rejected.Load())
}
g := accountMailFixture(t, 0)
var firstToken string
for request := range 5 {
if err := g.service.RequestVerification(t.Context(), g.session.Digest); err != nil {
t.Fatal(err)
}
messages := g.drain(t)
if request == 0 {
firstToken = mailToken(t, messages[0])
}
if err := g.service.RequestVerification(t.Context(), g.session.Digest); !errors.Is(err, authmail.ErrLimited) {
t.Fatalf("burst limit: %v", err)
}
g.clock.advance(time.Minute)
}
if err := g.service.RequestVerification(t.Context(), g.session.Digest); !errors.Is(err, authmail.ErrLimited) {
t.Fatalf("hour limit: %v", err)
}
if _, err := g.service.Inspect(t.Context(), firstToken); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatal("replaced link survived")
}
g.clock.advance(time.Hour)
if err := g.service.RequestVerification(t.Context(), g.session.Digest); err != nil {
t.Fatal("rate window did not recover", err)
}
}
func TestMailRestartAndAddressCollision(t *testing.T) {
f := accountMailFixture(t, 0)
if err := f.service.RequestChange(t.Context(), f.session.Digest, mailPassword, "new@example.test"); err != nil {
t.Fatal(err)
}
messages := f.drain(t)
first, second := mailToken(t, messages[0]), mailToken(t, messages[1])
if _, err := f.service.Confirm(t.Context(), first); err != nil {
t.Fatal(err)
}
other := f.user
other.ID = "another_mail_user"
other.Username = "another.reader"
other.Email = "new@example.test"
hash, _ := accountMailHash()
if err := f.store.CreateUser(t.Context(), other, hash); err != nil {
t.Fatal(err)
}
reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if err = reopened.RequireMailSchema(t.Context()); err != nil {
t.Fatal(err)
}
repo, _, err := reopened.AccountMail(mailsqlite.Options{EncryptionKey: bytes.Repeat([]byte{31}, 32), Now: f.clock.now})
if err != nil {
t.Fatal(err)
}
service, err := authmail.New(repo, mailConfig(f.clock))
if err != nil {
t.Fatal(err)
}
if _, err = service.Confirm(t.Context(), second); !errors.Is(err, authmail.ErrAddressUnavailable) {
t.Fatalf("collision not rechecked: %v", err)
}
if _, err = service.Inspect(t.Context(), second); err != nil {
t.Fatal("collision consumed token", err)
}
user, _, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.Email != f.user.Email {
t.Fatal("collision overwrote canonical email")
}
var version int
if version, err = f.store.CurrentSchema(t.Context()); err != nil || version != 11 {
t.Fatalf("base schema changed: %d %v", version, err)
}
}
func TestMailCommitUsesFreshTimeAndCredentials(t *testing.T) {
for _, change := range []string{"expiry", "credential"} {
t.Run(change, func(t *testing.T) {
f := accountMailFixture(t, 0)
f.verify(t)
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
config := mailConfig(f.clock)
config.Compose = func(intent authmail.MailIntent) (authmail.MessageContent, error) {
if intent.Kind == "password-changed" {
if change == "expiry" {
f.clock.advance(authmail.Lifetime)
} else {
if _, err := f.store.db.Exec(`UPDATE gwf_password_credentials SET password_hash='concurrent-credential'`); err != nil {
t.Fatal(err)
}
}
}
return authmail.DefaultComposer(intent)
}
service, err := authmail.New(f.repo, config)
if err != nil {
t.Fatal(err)
}
if err = service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("stale completion: %v", err)
}
_, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || auth.VerifyPassword(hash, "a different safe password") {
t.Fatal("stale completion changed password")
}
if got := f.drain(t); len(got) != 0 {
t.Fatal("uncommitted reset sent notice")
}
})
}
}
func TestMailMigrationRollbackAndMixedCaseLegacyAddress(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "rollback.sqlite"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
if _, err = store.db.Exec(`CREATE TABLE gwf_verified_emails(conflicting_schema TEXT)`); err != nil {
t.Fatal(err)
}
if err = store.MigrateMail(t.Context()); err == nil {
t.Fatal("schema conflict ignored")
}
var count int
if err = store.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name IN ('gwf_mail_outbox','gwf_account_mail_migrations')`).Scan(&count); err != nil || count != 0 {
t.Fatal("partial mail migration remained")
}
if err = store.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal("base schema damaged", err)
}
f := accountMailFixture(t, 0)
if _, err = f.store.db.Exec(`UPDATE gwf_users SET email='READER@Example.Test'`); err != nil {
t.Fatal(err)
}
f.verify(t)
if err = f.service.RequestReset(t.Context(), "Reader@Example.Test"); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
if err = f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil {
t.Fatal("legacy address reset", err)
}
if notices := f.drain(t); len(notices) != 1 || notices[0].To != "reader@example.test" {
t.Fatal("canonical notice recipient")
}
}
+424 -27
View File
@@ -7,6 +7,8 @@ import (
"database/sql"
"encoding/json"
"errors"
"slices"
"strconv"
"time"
"gamertan.com/web/organizations"
@@ -123,10 +125,28 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or
return nil
}
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, audit organizations.AuditEvent) error {
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
func (store *Store) CreateInvitationWithRoles(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
return store.CreateInvitation(ctx, invitation, ownerRole, audit)
}
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || ownerRole != "" && !safeName(ownerRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
return errors.New("authsqlite: invalid invitation")
}
roles, err := invitation.RoleNames()
if err != nil {
return err
}
if invitation.RequiredOwnerRole != "" && invitation.RequiredOwnerRole != ownerRole || audit.ActorUserID != invitation.InvitedByUserID || audit.Action != "invitation.create" || audit.ResourceType != "invitation" || audit.ResourceID != invitation.ID {
return errors.New("authsqlite: invalid invitation authority")
}
if ownerRole != "" && slices.Contains(roles, ownerRole) {
invitation.RequiredOwnerRole = ownerRole
}
rolesJSON, err := json.Marshal(invitation.DirectRoles)
if err != nil {
return err
}
teamIDs, err := json.Marshal(invitation.TeamIDs)
if err != nil {
return err
@@ -136,12 +156,33 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil {
return err
}
if invitation.RequiredOwnerRole != "" {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, invitation.RequiredOwnerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
}
if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at,id,direct_role,team_ids_json)
SELECT ?,?,?,?,?,?,?,?,? FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id
WHERE m.organization_id=? AND m.user_id=? AND m.status='active' AND o.status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.ID, invitation.DirectRole, teamIDs, invitation.OrganizationID, invitation.InvitedByUserID)
for _, role := range roles {
var count int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_roles WHERE name=?`, role).Scan(&count); err != nil {
return err
}
if count != 1 {
return errors.New("authsqlite: invitation role has not been seeded")
}
}
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at,id,direct_role,team_ids_json,direct_roles_json,required_owner_role)
SELECT ?,?,?,?,?,?,?,?,?,?,? FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id
WHERE m.organization_id=? AND m.user_id=? AND m.status='active' AND o.status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.ID, invitation.DirectRole, teamIDs, rolesJSON, invitation.RequiredOwnerRole, invitation.OrganizationID, invitation.InvitedByUserID)
if err != nil {
return err
}
@@ -160,8 +201,8 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now
}
var invitation organizations.Invitation
var created, expires int64
var teamIDs []byte
err := store.db.QueryRowContext(ctx, `SELECT id,organization_id,email_normalized,invited_by_user_id,direct_role,team_ids_json,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.ID, &invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &invitation.DirectRole, &teamIDs, &created, &expires)
var teamIDs, rolesJSON []byte
err := store.db.QueryRowContext(ctx, `SELECT id,organization_id,email_normalized,invited_by_user_id,direct_role,team_ids_json,direct_roles_json,required_owner_role,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.ID, &invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &invitation.DirectRole, &teamIDs, &rolesJSON, &invitation.RequiredOwnerRole, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return organizations.Invitation{}, organizations.ErrInvitationNotFound
}
@@ -172,13 +213,20 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now
if err = json.Unmarshal(teamIDs, &invitation.TeamIDs); err != nil || !validInvitationTeamIDs(invitation.TeamIDs) {
return organizations.Invitation{}, organizations.ErrInvitationNotFound
}
if !decodeInvitationRoles(&invitation, rolesJSON) {
return organizations.Invitation{}, organizations.ErrInvitationNotFound
}
invitation.CreatedAt = time.Unix(created, 0).UTC()
invitation.ExpiresAt = time.Unix(expires, 0).UTC()
return invitation, nil
}
func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time, audit organizations.AuditEvent) error {
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) {
return store.AcceptInvitationWithRoles(ctx, digest, userID, "", acceptedAt, audit)
}
func (store *Store) AcceptInvitationWithRoles(ctx context.Context, digest [32]byte, userID, ownerRole string, acceptedAt time.Time, audit organizations.AuditEvent) error {
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) || audit.ActorUserID != userID || ownerRole != "" && !safeName(ownerRole) {
return organizations.ErrInvitationNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
@@ -186,16 +234,45 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
return err
}
defer tx.Rollback()
var invitationID, organizationID, directRole, invitedBy string
var teamIDsJSON []byte
err = tx.QueryRowContext(ctx, `SELECT i.id,i.organization_id,i.direct_role,i.team_ids_json,i.invited_by_user_id FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized JOIN gwf_organizations o ON o.id=i.organization_id AND o.status='active' WHERE i.token_hash=? AND i.used_at IS NULL AND i.revoked_at IS NULL AND i.expires_at>?`, userID, digest[:], acceptedAt.Unix()).Scan(&invitationID, &organizationID, &directRole, &teamIDsJSON, &invitedBy)
// Serialize acceptance before reading token state, including competing users.
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET expires_at=expires_at WHERE token_hash=?`, digest[:]); err != nil {
return err
}
var invitationID, organizationID, directRole, invitedBy, requiredOwnerRole string
var teamIDsJSON, rolesJSON []byte
err = tx.QueryRowContext(ctx, `SELECT i.id,i.organization_id,i.direct_role,i.team_ids_json,i.invited_by_user_id,i.direct_roles_json,i.required_owner_role FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized AND u.status='active' AND u.registration_pending=0 JOIN gwf_organizations o ON o.id=i.organization_id AND o.status='active' WHERE i.token_hash=? AND i.used_at IS NULL AND i.revoked_at IS NULL AND i.expires_at>? AND NOT EXISTS (SELECT 1 FROM gwf_organization_memberships m WHERE m.organization_id=i.organization_id AND m.user_id=u.id)`, userID, digest[:], acceptedAt.Unix()).Scan(&invitationID, &organizationID, &directRole, &teamIDsJSON, &invitedBy, &rolesJSON, &requiredOwnerRole)
if errors.Is(err, sql.ErrNoRows) {
return organizations.ErrInvitationNotFound
}
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,'active',?) ON CONFLICT(organization_id,user_id) DO UPDATE SET status='active'`, organizationID, userID, acceptedAt.Unix()); err != nil {
invitation := organizations.Invitation{DirectRole: directRole, RequiredOwnerRole: requiredOwnerRole}
if !decodeInvitationRoles(&invitation, rolesJSON) {
return organizations.ErrInvitationNotFound
}
roles, _ := invitation.RoleNames()
if audit.OrganizationID != organizationID || audit.ResourceType != "invitation" || audit.ResourceID != invitationID || audit.Action != "invitation.accept" {
return organizations.ErrInvitationNotFound
}
// Stored authority survives which application service receives the link.
// The caller's owner role also protects pre-schema-10 single-role invitations.
if requiredOwnerRole == "" && ownerRole != "" && slices.Contains(roles, ownerRole) {
requiredOwnerRole = ownerRole
}
if err = lockActiveMembershipActor(ctx, tx, organizationID, invitedBy); err != nil {
return err
}
if requiredOwnerRole != "" {
isOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, invitedBy, requiredOwnerRole)
if ownerErr != nil {
return ownerErr
}
if !isOwner {
return organizations.ErrOwnerAuthority
}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,'active',?)`, organizationID, userID, acceptedAt.Unix()); err != nil {
return err
}
var teamIDs []string
@@ -210,11 +287,12 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
return err
}
}
if directRole != "" {
if !safeName(directRole) {
return organizations.ErrInvitationNotFound
for i, role := range roles {
bindingID := "invite-" + invitationID
if len(invitation.DirectRoles) > 0 {
bindingID += "-" + strconv.Itoa(i)
}
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, "invite-"+invitationID, organizationID, userID, directRole, invitedBy, acceptedAt.Unix(), directRole)
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, bindingID, organizationID, userID, role, invitedBy, acceptedAt.Unix(), role)
if err != nil {
return err
}
@@ -229,9 +307,6 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrInvitationNotFound
}
if organizationID != audit.OrganizationID {
return organizations.ErrInvitationNotFound
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
@@ -261,6 +336,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")
@@ -305,6 +408,17 @@ func (store *Store) OrganizationByID(ctx context.Context, organizationID string)
}
func (store *Store) UpdateOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, audit organizations.AuditEvent) error {
return store.updateOrganization(ctx, value, expectedRevision, "", audit)
}
func (store *Store) UpdateOwnedOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, ownerRole string, audit organizations.AuditEvent) error {
if !safeName(ownerRole) || value.Personal || value.Status != "active" || audit.Action != "organization.update" || audit.ResourceType != "organization" || audit.ResourceID != value.ID {
return errors.New("authsqlite: invalid owner-managed organization update")
}
return store.updateOrganization(ctx, value, expectedRevision, ownerRole, audit)
}
func (store *Store) updateOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, ownerRole string, audit organizations.AuditEvent) error {
if !validOrganization(value) || expectedRevision < 1 || value.Revision != expectedRevision+1 || !validOrganizationAudit(audit, value.ID) {
return errors.New("authsqlite: invalid organization update")
}
@@ -313,6 +427,11 @@ func (store *Store) UpdateOrganization(ctx context.Context, value organizations.
return err
}
defer tx.Rollback()
if ownerRole != "" {
if err = lockOrganizationOwner(ctx, tx, value.ID, audit.ActorUserID, ownerRole); err != nil {
return err
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_organizations SET slug=?,name=?,status=?,revision=?,updated_at=? WHERE id=? AND revision=?`, value.Slug, value.Name, value.Status, value.Revision, value.UpdatedAt.Unix(), value.ID, expectedRevision)
if err != nil {
return err
@@ -402,6 +521,12 @@ func (store *Store) SetMembershipStatus(ctx context.Context, organizationID, use
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err
}
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, organizationID, audit.ActorUserID, userID, ownerRole); err != nil {
return err
}
if status != "active" {
if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
return err
@@ -425,6 +550,64 @@ 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 {
return store.changeMembershipStatus(ctx, input, ownerRole, audit, false)
}
func (store *Store) ChangeOwnedMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) error {
return store.changeMembershipStatus(ctx, input, ownerRole, audit, true)
}
func (store *Store) changeMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent, requireOwner bool) 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 requireOwner {
err = lockOrganizationOwner(ctx, tx, input.OrganizationID, input.ActorUserID, ownerRole)
} else {
err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID)
}
if 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 = requireOwnerAuthorityForOwnerTarget(ctx, tx, input.OrganizationID, input.ActorUserID, input.UserID, ownerRole); err != nil {
return err
}
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
@@ -434,9 +617,18 @@ func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err
}
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, organizationID, audit.ActorUserID, userID, ownerRole); err != nil {
return err
}
if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
return err
}
if err = revokePendingMembershipInvitations(ctx, tx, organizationID, userID, audit.CreatedAt); 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=?)`, userID, organizationID); err != nil {
return err
}
@@ -456,6 +648,152 @@ 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 {
return store.removeMembershipIfCurrent(ctx, input, ownerRole, audit, false)
}
func (store *Store) RemoveOwnedMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) error {
return store.removeMembershipIfCurrent(ctx, input, ownerRole, audit, true)
}
func (store *Store) removeMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent, requireOwner bool) 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 requireOwner {
err = lockOrganizationOwner(ctx, tx, input.OrganizationID, input.ActorUserID, ownerRole)
} else {
err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID)
}
if 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 = requireOwnerAuthorityForOwnerTarget(ctx, tx, input.OrganizationID, input.ActorUserID, input.UserID, ownerRole); err != nil {
return err
}
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
return err
}
if err = revokePendingMembershipInvitations(ctx, tx, input.OrganizationID, input.UserID, audit.CreatedAt); 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()
}
// Removing a member invalidates older enrollment offers too. A deliberate new
// invitation may be issued later; an old link cannot undo this transaction.
func revokePendingMembershipInvitations(ctx context.Context, tx *sql.Tx, organizationID, userID string, at time.Time) error {
_, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=?
WHERE organization_id=? AND email_normalized=(SELECT email_normalized FROM gwf_users WHERE id=?)
AND used_at IS NULL AND revoked_at IS NULL`, at.Unix(), organizationID, userID)
return err
}
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' AND u.registration_pending=0)`, organizationID, actorUserID, organizationID, actorUserID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrMembershipNotFound
}
return nil
}
func lockOrganizationOwner(ctx context.Context, tx *sql.Tx, organizationID, actorUserID, ownerRole string) error {
if err := lockActiveMembershipActor(ctx, tx, organizationID, actorUserID); err != nil {
return err
}
var personal bool
if err := tx.QueryRowContext(ctx, `SELECT personal FROM gwf_organizations WHERE id=?`, organizationID).Scan(&personal); err != nil {
return err
}
if personal {
return organizations.ErrPersonalOrganization
}
owner, err := hasDirectOwnerRole(ctx, tx, organizationID, actorUserID, ownerRole)
if err != nil {
return err
}
if !owner {
return organizations.ErrOwnerAuthority
}
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 requireOwnerAuthorityForOwnerTarget(ctx context.Context, tx *sql.Tx, organizationID, actorUserID, targetUserID, ownerRole string) error {
targetIsOwner, err := hasDirectOwnerRole(ctx, tx, organizationID, targetUserID, ownerRole)
if err != nil || !targetIsOwner {
return err
}
actorIsOwner, err := hasDirectOwnerRole(ctx, tx, organizationID, actorUserID, ownerRole)
if err != nil {
return err
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
return nil
}
func hasDirectOwnerRole(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) (bool, error) {
var count 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(&count); err != nil {
return false, err
}
return count > 0, 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,21 +802,42 @@ 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' AND u.registration_pending=0
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
AND b.revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&otherActiveOwners); err != nil {
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")
}
rows, err := store.db.QueryContext(ctx, `SELECT id,email_normalized,invited_by_user_id,direct_role,team_ids_json,created_at,expires_at,COALESCE(used_at,0),COALESCE(revoked_at,0) FROM gwf_organization_invitations WHERE organization_id=? ORDER BY created_at DESC LIMIT ?`, organizationID, limit)
rows, err := store.db.QueryContext(ctx, `SELECT id,email_normalized,invited_by_user_id,direct_role,team_ids_json,direct_roles_json,required_owner_role,created_at,expires_at,COALESCE(used_at,0),COALESCE(revoked_at,0) FROM gwf_organization_invitations WHERE organization_id=? ORDER BY created_at DESC,id LIMIT ?`, organizationID, limit)
if err != nil {
return nil, err
}
@@ -487,13 +846,16 @@ func (store *Store) Invitations(ctx context.Context, organizationID string, limi
for rows.Next() {
var value organizations.Invitation
var created, expires, used, revoked int64
var teamIDs []byte
if err = rows.Scan(&value.ID, &value.Email, &value.InvitedByUserID, &value.DirectRole, &teamIDs, &created, &expires, &used, &revoked); err != nil {
var teamIDs, rolesJSON []byte
if err = rows.Scan(&value.ID, &value.Email, &value.InvitedByUserID, &value.DirectRole, &teamIDs, &rolesJSON, &value.RequiredOwnerRole, &created, &expires, &used, &revoked); err != nil {
return nil, err
}
if json.Unmarshal(teamIDs, &value.TeamIDs) != nil || !validInvitationTeamIDs(value.TeamIDs) {
return nil, errors.New("authsqlite: stored invitation is invalid")
}
if !decodeInvitationRoles(&value, rolesJSON) {
return nil, errors.New("authsqlite: stored invitation roles are invalid")
}
value.OrganizationID = organizationID
value.CreatedAt, value.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
if used != 0 {
@@ -524,6 +886,14 @@ func validInvitationTeamIDs(teamIDs []string) bool {
return true
}
func decodeInvitationRoles(invitation *organizations.Invitation, raw []byte) bool {
if len(raw) > 4096 || json.Unmarshal(raw, &invitation.DirectRoles) != nil || invitation.RequiredOwnerRole != "" && !safeName(invitation.RequiredOwnerRole) {
return false
}
_, err := invitation.RoleNames()
return err == nil
}
func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID string, teamIDs []string) error {
for _, teamID := range teamIDs {
var count int
@@ -537,8 +907,8 @@ func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID str
return nil
}
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID string, revokedAt time.Time, audit organizations.AuditEvent) error {
if !opaqueID(organizationID) || !opaqueID(invitationID) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID, ownerRole string, revokedAt time.Time, audit organizations.AuditEvent) error {
if !opaqueID(organizationID) || !opaqueID(invitationID) || ownerRole != "" && !safeName(ownerRole) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
return organizations.ErrInvitationNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
@@ -546,6 +916,33 @@ func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invita
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err
}
var invitation organizations.Invitation
var rolesJSON []byte
if err = tx.QueryRowContext(ctx, `SELECT direct_role,direct_roles_json,required_owner_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&invitation.DirectRole, &rolesJSON, &invitation.RequiredOwnerRole); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return organizations.ErrInvitationNotFound
}
return err
}
if !decodeInvitationRoles(&invitation, rolesJSON) {
return organizations.ErrInvitationNotFound
}
roles, _ := invitation.RoleNames()
if invitation.RequiredOwnerRole == "" && ownerRole != "" && slices.Contains(roles, ownerRole) {
invitation.RequiredOwnerRole = ownerRole
}
if invitation.RequiredOwnerRole != "" {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, invitation.RequiredOwnerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, revokedAt.Unix(), organizationID, invitationID)
if err != nil {
return err
+175
View File
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"testing"
"gamertan.com/web/organizations"
)
type ownedOperation struct {
name, action string
apply func(context.Context, roleSetFixture, string, string) error
}
func ownedOperations() []ownedOperation {
return []ownedOperation{
{"profile", "organization.update", func(ctx context.Context, f roleSetFixture, actor, _ string) error {
_, err := f.organizations.UpdateOwnedOrganization(ctx, organizations.UpdateOrganization{ID: f.org.ID, Slug: "updated-business", Name: "Updated business", ActorUserID: actor, ExpectedRevision: 1, RequestID: "request-profile"})
return err
}},
{"suspend", "membership.suspended", func(ctx context.Context, f roleSetFixture, actor, target string) error {
return f.organizations.ChangeOwnedMembershipStatus(ctx, organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, ExpectedStatus: "active", Status: "suspended", RequestID: "request-status"})
}},
{"remove", "membership.remove", func(ctx context.Context, f roleSetFixture, actor, target string) error {
return f.organizations.RemoveOwnedMembershipIfCurrent(ctx, organizations.MembershipRemoval{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, ExpectedStatus: "active", RequestID: "request-remove"})
}},
}
}
func TestOwnedManagementRechecksActorInWriteTransaction(t *testing.T) {
for _, operation := range ownedOperations() {
for _, change := range []struct{ name, sql string }{
{"role revoked", `UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id='customer-12345'`},
{"role narrowed", `UPDATE gwf_access_bindings SET project_id=(SELECT id FROM gwf_projects LIMIT 1) WHERE subject_id='customer-12345'`},
{"actor suspended", `UPDATE gwf_organization_memberships SET status='suspended' WHERE user_id='customer-12345'`},
{"actor removed", `DELETE FROM gwf_organization_memberships WHERE user_id='customer-12345'`},
{"account disabled", `UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`},
{"registration incomplete", `UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`},
{"organization archived", `UPDATE gwf_organizations SET status='archived'`},
{"personal organization", `UPDATE gwf_organizations SET personal=1,personal_owner_user_id='customer-12345'`},
} {
t.Run(operation.name+"/"+change.name, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
if change.name == "role narrowed" {
if _, err := f.organizations.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: f.org.ID, Slug: "project", Name: "Project"}); err != nil {
t.Fatal(err)
}
}
// Model a change committed after the caller displayed/authorized the
// operation. The repository must not rely on that earlier decision.
if _, err := f.store.db.Exec(change.sql); err != nil {
t.Fatal(err)
}
if err := operation.apply(t.Context(), f, roleOwner, roleMember); err == nil {
t.Fatal("stale owner authority accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organizations WHERE id=? AND revision=1`, f.org.ID, 1)
})
}
}
}
func TestOwnedManagementDoesNotInheritDelegatedAdministratorSemantics(t *testing.T) {
for _, operation := range ownedOperations() {
t.Run(operation.name, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
if err := operation.apply(t.Context(), f, roleMember, roleMember); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner management: %v", err)
}
if err := operation.apply(t.Context(), f, roleOwner, roleMember); err != nil {
t.Fatal(err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1)
if err := operation.apply(t.Context(), f, roleOwner, roleMember); err == nil {
t.Fatal("replayed mutation accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1)
})
}
// Legacy callers still authorize non-owner administrative operations in
// their application policy; the new explicit methods do not alter that API.
f := newRoleSetFixture(t)
f.addMember(t)
err := f.organizations.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleMember, ExpectedStatus: "active", Status: "suspended"})
if err != nil {
t.Fatalf("delegated legacy operation changed: %v", err)
}
}
func TestOwnedMembershipPreservesLastOwnerAndRestoresSuspendedMember(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
for _, operation := range ownedOperations()[1:] {
if err := operation.apply(t.Context(), f, roleOwner, roleOwner); !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("%s last owner: %v", operation.name, err)
}
}
if err := ownedOperations()[1].apply(t.Context(), f, roleOwner, roleMember); err != nil {
t.Fatal(err)
}
input := organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, ExpectedStatus: "suspended", Status: "active"}
if err := f.organizations.ChangeOwnedMembershipStatus(t.Context(), input); err != nil {
t.Fatal(err)
}
if err := f.organizations.ChangeOwnedMembershipStatus(t.Context(), input); !errors.Is(err, organizations.ErrRevisionConflict) {
t.Fatalf("stale reactivation: %v", err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1)
}
func TestOwnedManagementRollsBackWithAuditFailure(t *testing.T) {
for _, operation := range ownedOperations() {
t.Run(operation.name, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
_, pending := f.invite(t, "buyer")
team, err := f.organizations.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: f.org.ID, Slug: "team", Name: "Team", ActorUserID: roleOwner})
if err != nil {
t.Fatal(err)
}
if err = f.organizations.AddTeamMember(t.Context(), team.ID, roleMember, roleOwner); err != nil {
t.Fatal(err)
}
if _, err = f.store.db.Exec(`CREATE TRIGGER reject_owned_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='` + operation.action + `' BEGIN SELECT RAISE(ABORT,'injected audit failure'); END`); err != nil {
t.Fatal(err)
}
if err = operation.apply(t.Context(), f, roleOwner, roleMember); err == nil {
t.Fatal("audit failure accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND revoked_at IS NULL`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organizations WHERE id=? AND revision=1`, f.org.ID, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NULL`, pending.ID, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 0)
})
}
}
func TestConcurrentOwnedManagementHasOneWinner(t *testing.T) {
for _, operation := range ownedOperations() {
t.Run(operation.name, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
start, results := make(chan struct{}), make(chan error, 2)
for range 2 {
go func() { <-start; results <- operation.apply(t.Context(), f, roleOwner, roleMember) }()
}
close(start)
success, stale := 0, 0
for range 2 {
err := <-results
switch {
case err == nil:
success++
case errors.Is(err, organizations.ErrRevisionConflict), errors.Is(err, organizations.ErrMembershipNotFound):
stale++
default:
t.Fatalf("concurrent mutation: %v", err)
}
}
if success != 1 || stale != 1 {
t.Fatalf("success=%d stale=%d", success, stale)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1)
})
}
}
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"gamertan.com/web/organizations"
)
// CreateOwnedOrganization atomically creates a new organization and its first
// direct owner. It never grants authority in an existing organization.
func (store *Store) CreateOwnedOrganization(ctx context.Context, setup organizations.OwnedOrganization) error {
organization, membership, binding := setup.Organization, setup.Membership, setup.OwnerBinding
audit, accessAudit := setup.OrganizationAudit, setup.AccessAudit
if !validOrganization(organization) || organization.Status != "active" || organization.Revision != 1 ||
membership.OrganizationID != organization.ID || !opaqueID(membership.UserID) || membership.Status != "active" || !membership.JoinedAt.Equal(organization.CreatedAt) ||
!validOwnerBinding(binding, organization.ID, membership.UserID) || !binding.GrantedAt.Equal(organization.CreatedAt) ||
!validOrganizationAudit(audit, organization.ID) || audit.ActorUserID != membership.UserID || audit.Action != "organization.create" || audit.ResourceType != "organization" || audit.ResourceID != organization.ID ||
!validAccessAudit(accessAudit) || accessAudit.OrganizationID != organization.ID || accessAudit.ActorUserID != membership.UserID || accessAudit.Action != "access.binding.grant" || accessAudit.ResourceType != "binding" || accessAudit.ResourceID != binding.ID || accessAudit.RequestID != audit.RequestID {
return errors.New("authsqlite: invalid owned organization")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var personalOwner any
if organization.Personal {
personalOwner = membership.UserID
}
// The first statement acquires the writer lock and validates active, completed
// identity inside the transaction; account suspension cannot race the grant.
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at)
SELECT ?,?,?,?,?,?,?,?,? FROM gwf_users WHERE id=? AND status='active' AND registration_pending=0`,
organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner,
organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix(), membership.UserID)
if err != nil {
return err
}
if changed, err := result.RowsAffected(); err != nil || changed != 1 {
if err != nil {
return err
}
return organizations.ErrOwnerAuthority
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, organization.ID, membership.UserID, membership.Status, membership.JoinedAt.Unix()); 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=?`,
binding.ID, organization.ID, membership.UserID, binding.Role, membership.UserID, binding.GrantedAt.Unix(), binding.Role)
if err != nil {
return err
}
if changed, err := result.RowsAffected(); err != nil || changed != 1 {
if err != nil {
return err
}
return errors.New("authsqlite: initial owner role has not been seeded")
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, accessAudit); err != nil {
return err
}
return tx.Commit()
}
var _ organizations.OwnedOrganizationRepository = (*Store)(nil)
+264
View File
@@ -0,0 +1,264 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/organizations"
)
func ownedOrganizationFixture(t *testing.T) (*Store, *organizations.Service, access.Policy, organizations.CreateOrganization) {
t.Helper()
store, err := Open(filepath.Join(t.TempDir(), "owned.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
now := time.Unix(2000, 0).UTC()
if _, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,registration_pending,created_at,updated_at)
VALUES('customer-12345','customer','customer','customer@example.test','customer@example.test','Customer','active',0,2000,2000)`); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"customer.owner": "Customer owner", "home.owner": "Merchant owner"},
Permissions: map[string]string{"customer.purchase": "Purchase", "merchant.manage": "Manage merchant"},
Grants: map[string][]string{"customer.owner": {"customer.purchase"}, "home.owner": {"merchant.manage"}},
}
accessService, err := access.New(store, policy, access.Options{})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
service, err := organizations.New(store, organizations.Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
return store, service, policy, organizations.CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345", RequestID: "request-creation"}
}
func countOwnedRows(t *testing.T, store *Store, want int) {
t.Helper()
for _, table := range []string{"gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_access_audit_events"} {
var count int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil {
t.Fatal(err)
}
expected := want
if table == "gwf_access_audit_events" {
expected *= 2
}
if count != expected {
t.Errorf("%s count=%d want=%d", table, count, expected)
}
}
}
func TestOwnedOrganizationCommitsScopedOwnerAndAudits(t *testing.T) {
store, service, policy, input := ownedOrganizationFixture(t)
organization, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
countOwnedRows(t, store, 1)
accessService, err := access.New(store, policy, access.Options{})
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
scope string
permission string
want bool
}{
{organization.ID, "customer.purchase", true},
{organization.ID, "merchant.manage", false},
{"other-org-12345", "customer.purchase", false},
} {
decision, err := accessService.Authorize(t.Context(), input.OwnerUserID, access.Scope{OrganizationID: test.scope}, test.permission)
if err != nil || decision.Allowed != test.want {
t.Fatalf("scope=%s permission=%s decision=%+v err=%v", test.scope, test.permission, decision, err)
}
}
for _, action := range []string{"organization.create", "access.binding.grant"} {
var actor, request string
if err = store.db.QueryRow(`SELECT actor_user_id,request_id FROM gwf_access_audit_events WHERE organization_id=? AND action=?`, organization.ID, action).Scan(&actor, &request); err != nil {
t.Fatal(err)
}
if actor != input.OwnerUserID || request != input.RequestID {
t.Fatalf("audit actor=%q request=%q", actor, request)
}
}
if _, err = service.CreateOwnedOrganization(t.Context(), input); err == nil {
t.Fatal("duplicate slug accepted")
}
countOwnedRows(t, store, 1)
}
func TestOwnedOrganizationRollsBackEveryWriteFailure(t *testing.T) {
for _, stage := range []struct{ table, when string }{
{"gwf_organizations", ""}, {"gwf_organization_memberships", ""}, {"gwf_access_bindings", ""},
{"gwf_access_audit_events", " WHEN NEW.action='organization.create'"},
{"gwf_access_audit_events", " WHEN NEW.action='access.binding.grant'"},
} {
t.Run(stage.table+stage.when, func(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
if _, err := store.db.Exec(`CREATE TRIGGER reject_creation BEFORE INSERT ON ` + stage.table + stage.when + ` BEGIN SELECT RAISE(ABORT,'injected write failure'); END`); err != nil {
t.Fatal(err)
}
if organization, err := service.CreateOwnedOrganization(t.Context(), input); err == nil || organization.ID != "" {
t.Fatalf("organization=%+v err=%v", organization, err)
}
countOwnedRows(t, store, 0)
})
}
}
func TestOwnedOrganizationRejectsMissingRoleAndUnavailableOwner(t *testing.T) {
for _, change := range []string{
`DELETE FROM gwf_access_role_permissions WHERE role_name='customer.owner'; DELETE FROM gwf_access_roles WHERE name='customer.owner'`,
`UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`,
`UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`,
`DELETE FROM gwf_users WHERE id='customer-12345'`,
} {
t.Run(change, func(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
if _, err := store.db.Exec(change); err != nil {
t.Fatal(err)
}
if organization, err := service.CreateOwnedOrganization(t.Context(), input); err == nil || organization.ID != "" {
t.Fatalf("organization=%+v err=%v", organization, err)
}
countOwnedRows(t, store, 0)
})
}
}
func TestConcurrentOwnedOrganizationCreationHasOneCompleteWinner(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
var workers sync.WaitGroup
results := make(chan error, 8)
for range 8 {
workers.Go(func() { _, err := service.CreateOwnedOrganization(t.Context(), input); results <- err })
}
workers.Wait()
close(results)
winners := 0
for err := range results {
if err == nil {
winners++
}
}
if winners != 1 {
t.Fatalf("successful creations=%d", winners)
}
countOwnedRows(t, store, 1)
}
func TestLegacyOrganizationCreationRemainsMembershipOnly(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
if _, err := service.CreateOrganization(t.Context(), input); err != nil {
t.Fatal(err)
}
var bindings int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_bindings`).Scan(&bindings); err != nil {
t.Fatal(err)
}
if bindings != 0 {
t.Fatal("legacy creation unexpectedly granted authority")
}
}
func TestOwnedOrganizationSurvivesReopenAndProtectsLastOwner(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
organization, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
var sequence int
var name, path string
if err = store.db.QueryRow(`PRAGMA database_list`).Scan(&sequence, &name, &path); err != nil {
t.Fatal(err)
}
if err = store.Close(); err != nil {
t.Fatal(err)
}
reopened, err := OpenWithOptions(path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if err = reopened.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal(err)
}
countOwnedRows(t, reopened, 1)
service, err = organizations.New(reopened, organizations.Options{OwnerRole: "customer.owner"})
if err != nil {
t.Fatal(err)
}
err = service.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{
OrganizationID: organization.ID, UserID: input.OwnerUserID, ActorUserID: input.OwnerUserID, ExpectedStatus: "active",
})
if !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("last owner removal: %v", err)
}
err = service.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{
OrganizationID: organization.ID, UserID: input.OwnerUserID, ActorUserID: input.OwnerUserID, ExpectedStatus: "active", Status: "suspended",
})
if !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("last owner suspension: %v", err)
}
countOwnedRows(t, reopened, 1)
}
type capturedOwnedStore struct {
*Store
setup organizations.OwnedOrganization
}
func (store *capturedOwnedStore) CreateOwnedOrganization(_ context.Context, setup organizations.OwnedOrganization) error {
store.setup = setup
return nil
}
func TestOwnedOrganizationRejectsMismatchedAuthorityAndAudits(t *testing.T) {
for _, test := range []struct {
name string
change func(*organizations.OwnedOrganization)
}{
{"foreign member", func(s *organizations.OwnedOrganization) { s.Membership.OrganizationID = "other-org-12345" }},
{"foreign owner", func(s *organizations.OwnedOrganization) { s.OwnerBinding.SubjectID = "other-user-12345" }},
{"foreign scope", func(s *organizations.OwnedOrganization) { s.OwnerBinding.Scope.OrganizationID = "other-org-12345" }},
{"narrow scope", func(s *organizations.OwnedOrganization) { s.OwnerBinding.Scope.ProjectID = "project-12345" }},
{"team owner", func(s *organizations.OwnedOrganization) { s.OwnerBinding.SubjectKind = access.Team }},
{"wrong audit actor", func(s *organizations.OwnedOrganization) { s.AccessAudit.ActorUserID = "other-user-12345" }},
{"wrong audit binding", func(s *organizations.OwnedOrganization) { s.AccessAudit.ResourceID = "other-binding-12345" }},
{"wrong creation resource", func(s *organizations.OwnedOrganization) { s.OrganizationAudit.ResourceID = "other-org-12345" }},
{"wrong request", func(s *organizations.OwnedOrganization) { s.AccessAudit.RequestID = "other-request" }},
{"archived organization", func(s *organizations.OwnedOrganization) { s.Organization.Status = "archived" }},
} {
t.Run(test.name, func(t *testing.T) {
store, _, _, input := ownedOrganizationFixture(t)
capture := &capturedOwnedStore{Store: store}
service, err := organizations.New(capture, organizations.Options{OwnerRole: "customer.owner"})
if err != nil {
t.Fatal(err)
}
if _, err = service.CreateOwnedOrganization(t.Context(), input); err != nil {
t.Fatal(err)
}
test.change(&capture.setup)
if err = store.CreateOwnedOrganization(t.Context(), capture.setup); err == nil {
t.Fatal("invalid creation accepted")
}
countOwnedRows(t, store, 0)
})
}
}
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"errors"
"math"
"time"
"gamertan.com/web/auth"
"modernc.org/sqlite"
)
var _ auth.OwnProfileRepository = (*Store)(nil)
const ownProfileQuery = `SELECT u.id,u.username,u.email,u.display_name,u.profile_revision
FROM gwf_users u JOIN gwf_auth_sessions s ON s.user_id=u.id
WHERE s.token_hash=? AND s.expires_at>? AND u.status='active'
AND u.registration_pending=0 AND u.password_change_required=0`
func scanOwnProfile(row interface{ Scan(...any) error }) (auth.OwnProfile, error) {
var profile auth.OwnProfile
err := row.Scan(&profile.UserID, &profile.Username, &profile.Email, &profile.DisplayName, &profile.Revision)
if errors.Is(err, sql.ErrNoRows) {
return auth.OwnProfile{}, auth.ErrProfileAccess
}
return profile, err
}
func (store *Store) OwnProfile(ctx context.Context, session [32]byte, now time.Time) (auth.OwnProfile, error) {
if zeroDigest(session) || now.IsZero() {
return auth.OwnProfile{}, auth.ErrProfileAccess
}
return scanOwnProfile(store.db.QueryRowContext(ctx, ownProfileQuery, session[:], now.Unix()))
}
func (store *Store) UpdateOwnProfile(ctx context.Context, change auth.ProfileEdit, audit auth.AuditEvent) (auth.OwnProfile, error) {
value, err := auth.NormalizeProfileValue(change.Field, change.Value)
if err != nil || !opaqueID(change.UserID) || zeroDigest(change.SessionDigest) || change.ExpectedRevision < 1 || change.ExpectedRevision == math.MaxInt64 ||
!validAuditEvent(audit) || audit.ActorUserID != change.UserID || audit.ResourceType != "user" || audit.ResourceID != change.UserID || audit.Action != "auth.profile."+change.Field ||
change.ExpectedPasswordHash != "" && (change.Field != "username" || len(change.ExpectedPasswordHash) > 1024) {
return auth.OwnProfile{}, auth.ErrProfileInput
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.OwnProfile{}, err
}
defer tx.Rollback()
// This first statement takes the writer lock and tests the real current
// session/account/revision together. No read-before-write lock upgrade race.
set := `display_name=?`
args := []any{value}
if change.Field == "username" {
set = `username=?,username_normalized=?`
args = append(args, normalize(value))
}
args = append(args, audit.CreatedAt.Unix(), change.UserID, change.ExpectedRevision, change.SessionDigest[:], audit.CreatedAt.Unix(), change.ExpectedPasswordHash, change.ExpectedPasswordHash)
result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET `+set+`,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?)
WHERE id=? AND profile_revision=? AND status='active' AND registration_pending=0 AND password_change_required=0
AND EXISTS (SELECT 1 FROM gwf_auth_sessions WHERE user_id=gwf_users.id AND token_hash=? AND expires_at>?)
AND (?='' OR EXISTS (SELECT 1 FROM gwf_password_credentials WHERE user_id=gwf_users.id AND password_hash=?))`, args...)
if err != nil {
var constraint *sqlite.Error
if errors.As(err, &constraint) && constraint.Code() == 2067 {
return auth.OwnProfile{}, auth.ErrUsernameUnavailable
}
return auth.OwnProfile{}, err
}
changed, err := result.RowsAffected()
if err != nil {
return auth.OwnProfile{}, err
}
if changed != 1 {
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
if err != nil {
return auth.OwnProfile{}, err
}
if profile.UserID != change.UserID {
return auth.OwnProfile{}, auth.ErrProfileAccess
}
return auth.OwnProfile{}, auth.ErrProfileConflict
}
if change.Field == "username" {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=? AND token_hash<>?`, change.UserID, change.SessionDigest[:]); err != nil {
return auth.OwnProfile{}, err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return auth.OwnProfile{}, err
}
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
if err != nil {
return auth.OwnProfile{}, err
}
if err = tx.Commit(); err != nil {
return auth.OwnProfile{}, err
}
return profile, nil
}
+212
View File
@@ -0,0 +1,212 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"crypto/sha256"
"errors"
"path/filepath"
"sync"
"testing"
"time"
"gamertan.com/web/auth"
)
type profileFixture struct {
store *Store
path string
now time.Time
user auth.User
session, other auth.Session
}
func newProfileFixture(t *testing.T) profileFixture {
t.Helper()
path := filepath.Join(t.TempDir(), "identity.sqlite")
store, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
now := time.Now().UTC().Truncate(time.Second)
user := auth.User{ID: "profile-user", Username: "profile.reader", Email: "profile@example.test", DisplayName: "Profile Reader", Status: "active", CreatedAt: now, UpdatedAt: now}
if err = store.CreateUser(t.Context(), user, "fixture-hash"); err != nil {
t.Fatal(err)
}
session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-session")), CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour)}
other := session
other.Digest = sha256.Sum256([]byte("other-session"))
for _, s := range []auth.Session{session, other} {
if err = store.CreateSession(t.Context(), s); err != nil {
t.Fatal(err)
}
}
return profileFixture{store, path, now, user, session, other}
}
func (f profileFixture) change(field, value string, revision int64) (auth.ProfileEdit, auth.AuditEvent) {
return auth.ProfileEdit{UserID: f.user.ID, SessionDigest: f.session.Digest, ExpectedRevision: revision, Field: field, Value: value},
auth.AuditEvent{ID: "profile-audit-" + field, ActorUserID: f.user.ID, Action: "auth.profile." + field, ResourceType: "user", ResourceID: f.user.ID, Summary: "Own profile field changed", CreatedAt: f.now}
}
func TestOwnProfileStableIdentityAndSessionPolicy(t *testing.T) {
f := newProfileFixture(t)
initial, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now)
if err != nil || initial.Revision != 1 {
t.Fatalf("initial revision: %d %v", initial.Revision, err)
}
change, audit := f.change("display_name", " Émilie ★ ", 1)
updated, err := f.store.UpdateOwnProfile(t.Context(), change, audit)
if err != nil || updated.DisplayName != "Émilie ★" || updated.Revision != 2 || updated.UserID != initial.UserID || updated.Username != initial.Username || updated.Email != initial.Email {
t.Fatalf("display update: %+v %v", updated, err)
}
if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil {
t.Fatal("display edit revoked session", err)
}
if _, err = f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) {
t.Fatalf("stale: %v", err)
}
change, audit = f.change("username", "new.reader", 2)
change.ExpectedPasswordHash = "fixture-hash"
updated, err = f.store.UpdateOwnProfile(t.Context(), change, audit)
if err != nil || updated.Username != "new.reader" || updated.Revision != 3 || updated.UserID != initial.UserID || updated.Email != initial.Email {
t.Fatalf("username update: %+v %v", updated, err)
}
if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); !errors.Is(err, auth.ErrProfileAccess) {
t.Fatalf("other session survived: %v", err)
}
user, hash, err := f.store.CredentialByIdentifier(t.Context(), "NEW.READER")
if err != nil || user.ID != initial.UserID || hash != "fixture-hash" {
t.Fatal("credential identity changed", err)
}
var count int
if err = f.store.db.QueryRow(`SELECT count(*) FROM gwf_audit_events WHERE actor_user_id=? AND resource_id=?`, f.user.ID, f.user.ID).Scan(&count); err != nil || count != 2 {
t.Fatalf("audits: %d %v", count, err)
}
reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if recovered, err := reopened.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || recovered != updated {
t.Fatalf("restart: %+v %v", recovered, err)
}
}
func TestOwnProfileAuthorizationAndRollback(t *testing.T) {
for _, test := range []struct{ name, sql string }{
{"revoked-session", `DELETE FROM gwf_auth_sessions`},
{"expired-session", `UPDATE gwf_auth_sessions SET expires_at=1`},
{"suspended", `UPDATE gwf_users SET status='suspended'`},
{"disabled", `UPDATE gwf_users SET status='disabled'`},
{"registration-pending", `UPDATE gwf_users SET registration_pending=1`},
{"password-change", `UPDATE gwf_users SET password_change_required=1`},
} {
t.Run(test.name, func(t *testing.T) {
f := newProfileFixture(t)
if _, err := f.store.db.Exec(test.sql); err != nil {
t.Fatal(err)
}
change, audit := f.change("display_name", "not allowed", 1)
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) {
t.Fatalf("access: %v", err)
}
})
}
f := newProfileFixture(t)
change, audit := f.change("username", "new.reader", 1)
change.UserID = "another-user"
audit.ActorUserID = change.UserID
audit.ResourceID = change.UserID
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) {
t.Fatalf("foreign user: %v", err)
}
change, audit = f.change("username", "new.reader", 1)
change.ExpectedPasswordHash = "old-verified-hash"
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) {
t.Fatalf("changed password: %v", err)
}
change.ExpectedPasswordHash = "fixture-hash"
if err := f.store.AppendAudit(t.Context(), audit); err != nil {
t.Fatal(err)
}
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); err == nil {
t.Fatal("duplicate audit accepted")
}
if profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || profile.Revision != 1 || profile.Username != f.user.Username {
t.Fatalf("rollback: %+v %v", profile, err)
}
if _, err := f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil {
t.Fatal("audit failure revoked session", err)
}
}
func TestOwnProfileUniquenessConcurrencyAndMigration(t *testing.T) {
f := newProfileFixture(t)
otherUser := f.user
otherUser.ID = "another-user"
otherUser.Username = "another.reader"
otherUser.Email = "another@example.test"
if err := f.store.CreateUser(t.Context(), otherUser, "fixture-hash"); err != nil {
t.Fatal(err)
}
change, audit := f.change("username", "ANOTHER.READER", 1)
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrUsernameUnavailable) {
t.Fatalf("unique name: %v", err)
}
second, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer second.Close()
var wg sync.WaitGroup
results := make(chan error, 2)
for _, store := range []*Store{f.store, second} {
wg.Add(1)
go func(store *Store) {
defer wg.Done()
change, audit := f.change("display_name", "New Name", 1)
_, err := store.UpdateOwnProfile(t.Context(), change, audit)
results <- err
}(store)
}
wg.Wait()
close(results)
success, conflict := 0, 0
for err := range results {
if err == nil {
success++
} else if errors.Is(err, auth.ErrProfileConflict) {
conflict++
} else {
t.Fatal(err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("concurrent writes: %d successes, %d conflicts", success, conflict)
}
// Recreate the actual previous schema without rewriting its identity rows.
if _, err = f.store.db.Exec(`ALTER TABLE gwf_users DROP COLUMN profile_revision`); err != nil {
t.Fatal(err)
}
if _, err = f.store.db.Exec(`DELETE FROM gamertan_web_migrations WHERE version=11`); err != nil {
t.Fatal(err)
}
if version, err := f.store.CurrentSchema(t.Context()); err != nil || version != 10 {
t.Fatalf("prior schema: %d %v", version, err)
}
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
t.Fatal("startup accepted old schema")
}
if err = f.store.Migrate(t.Context()); err != nil {
t.Fatal(err)
}
profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now)
if err != nil || profile.UserID != f.user.ID || profile.Email != f.user.Email || profile.DisplayName != "New Name" || profile.Revision != 1 {
t.Fatalf("migration: %+v %v", profile, err)
}
if err = f.store.Migrate(t.Context()); err != nil {
t.Fatal("idempotent migration", err)
}
}
+96
View File
@@ -3,8 +3,10 @@
package authsqlite
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"errors"
"time"
@@ -38,6 +40,100 @@ func (store *Store) ReplaceRecoveryCodes(ctx context.Context, userID string, dig
return tx.Commit()
}
func (store *Store) RecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
if zeroDigest(digest) || now.IsZero() {
return auth.User{}, authrecovery.ErrGrantNotFound
}
user, err := scanPasskeyUser(store.db.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_recovery_grants g JOIN gwf_users u ON u.id=g.user_id WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()))
if errors.Is(err, auth.ErrUserNotFound) {
return auth.User{}, authrecovery.ErrGrantNotFound
}
return user, err
}
func (store *Store) CompletePasskeyRecovery(ctx context.Context, completion authrecovery.PasskeyCompletion) error {
credential := completion.Credential
credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID)
if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || !validAuditEvent(completion.RecoveryAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID {
return errors.New("authsqlite: invalid passkey recovery completion")
}
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
for _, digest := range completion.RecoveryDigests {
if zeroDigest(digest) {
return errors.New("authsqlite: invalid recovery-code digest")
}
if _, exists := seen[digest]; exists {
return errors.New("authsqlite: duplicate recovery-code digest")
}
seen[digest] = struct{}{}
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var userID string
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return authrecovery.ErrGrantNotFound
}
if err != nil {
return err
}
if userID != credential.UserID {
return errors.New("authsqlite: passkey recovery identity mismatch")
}
var active, pending int
if err = tx.QueryRowContext(ctx, `SELECT status='active',registration_pending FROM gwf_users WHERE id=?`, userID).Scan(&active, &pending); err != nil || active != 1 || pending != 0 {
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
return auth.ErrInactiveUser
}
existing, err := tx.QueryContext(ctx, `SELECT credential_id FROM gwf_passkey_credentials WHERE user_id=?`, userID)
if err != nil {
return err
}
for existing.Next() {
var id []byte
if err = existing.Scan(&id); err != nil {
existing.Close()
return err
}
if bytes.Equal(id, credential.ID) {
existing.Close()
return errors.New("authsqlite: passkey credential already exists")
}
}
if err = existing.Close(); 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
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); 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, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, 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
}
return tx.Commit()
}
func (store *Store) ConsumeRecoveryCodeAndCreateGrant(ctx context.Context, userID string, codeDigest [32]byte, grant authrecovery.Grant, audit auth.AuditEvent) error {
if !opaqueID(userID) || zeroDigest(codeDigest) || grant.UserID != userID || zeroDigest(grant.Digest) || grant.CreatedAt.IsZero() || !grant.ExpiresAt.After(grant.CreatedAt) || grant.ExpiresAt.Sub(grant.CreatedAt) > 30*time.Minute || !validAuditEvent(audit) || audit.ResourceID != userID {
return errors.New("authsqlite: invalid recovery attempt")
+488
View File
@@ -0,0 +1,488 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"errors"
"slices"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/organizations"
)
type roleSetFixture struct {
store *Store
access *access.Service
organizations *organizations.Service
org organizations.Organization
now time.Time
}
const roleOwner = "customer-12345"
const roleMember = "member-12345678"
func newRoleSetFixture(t *testing.T) roleSetFixture {
t.Helper()
store, _, policy, input := ownedOrganizationFixture(t)
policy.Roles["buyer"] = "Buyer"
policy.Roles["billing"] = "Billing manager"
policy.Roles["member"] = "Member"
policy.Permissions["billing.manage"] = "Manage billing"
policy.Grants["buyer"] = []string{"customer.purchase"}
policy.Grants["billing"] = []string{"billing.manage"}
policy.Grants["member"] = nil
now := time.Unix(2100, 0).UTC()
accessService, err := access.New(store, policy, access.Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
service, err := organizations.New(store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
org, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
if _, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,registration_pending,created_at,updated_at)
VALUES(?,?,?,?,?,?,'active',0,2000,2000)`, roleMember, "member", "member", "member@example.test", "member@example.test", "Member"); err != nil {
t.Fatal(err)
}
return roleSetFixture{store: store, access: accessService, organizations: service, org: org, now: now}
}
func (f roleSetFixture) invite(t *testing.T, roles ...string) (string, organizations.Invitation) {
t.Helper()
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRoles: roles, Lifetime: time.Hour, RequestID: "request-invite"})
if err != nil {
t.Fatal(err)
}
return raw, invitation
}
func (f roleSetFixture) addMember(t *testing.T) {
t.Helper()
raw, _ := f.invite(t, "member")
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
t.Fatal(err)
}
}
func (f roleSetFixture) bindings(t *testing.T, user string) ([]string, []string) {
t.Helper()
bindings, err := f.access.OrganizationUserBindings(t.Context(), f.org.ID, 100)
if err != nil {
t.Fatal(err)
}
var ids, roles []string
for _, binding := range bindings {
if binding.SubjectID == user {
ids = append(ids, binding.ID)
roles = append(roles, binding.Role)
}
}
slices.Sort(ids)
slices.Sort(roles)
return ids, roles
}
func (f roleSetFixture) change(t *testing.T, actor, target string, roles ...string) error {
t.Helper()
ids, _ := f.bindings(t, target)
_, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, RequestID: "request-roles", Roles: roles, ExpectedBindingIDs: ids})
return err
}
func TestRoleSetCombinesCapabilitiesWithoutNarrowGrantChanges(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
project, err := f.organizations.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: f.org.ID, Slug: "project", Name: "Project"})
if err != nil {
t.Fatal(err)
}
narrow, err := f.access.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: roleMember, Role: "member", Scope: access.Scope{OrganizationID: f.org.ID, ProjectID: project.ID}, GrantedBy: roleOwner})
if err != nil {
t.Fatal(err)
}
if err = f.change(t, roleOwner, roleMember, "buyer", "billing"); err != nil {
t.Fatal(err)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v", roles)
}
for _, permission := range []string{"customer.purchase", "billing.manage"} {
decision, err := f.access.Authorize(t.Context(), roleMember, access.Scope{OrganizationID: f.org.ID}, permission)
if err != nil || !decision.Allowed {
t.Fatalf("permission=%s allowed=%v err=%v", permission, decision.Allowed, err)
}
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=? AND revoked_at IS NULL`, narrow.ID, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1)
if err = f.change(t, roleOwner, roleMember, "billing", "buyer"); !errors.Is(err, access.ErrRoleUnchanged) {
t.Fatalf("unchanged=%v", err)
}
if err = f.change(t, roleMember, roleMember, "member"); !errors.Is(err, access.ErrOwnerAuthority) {
t.Fatalf("non-owner bulk change=%v", err)
}
if err = f.change(t, roleOwner, roleOwner, "billing", "buyer"); !errors.Is(err, access.ErrLastOwner) {
t.Fatalf("last owner=%v", err)
}
if err = f.change(t, roleOwner, roleMember, "customer.owner", "billing"); err != nil {
t.Fatal(err)
}
if err = f.change(t, roleMember, roleOwner, "buyer"); err != nil {
t.Fatal(err)
}
if err = f.change(t, roleMember, roleMember, "buyer"); !errors.Is(err, access.ErrLastOwner) {
t.Fatalf("new last owner=%v", err)
}
}
func TestRoleSetConcurrentChangesHaveOneWinner(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
ids, _ := f.bindings(t, roleMember)
start := make(chan struct{})
results := make(chan error, 2)
for range 2 {
go func() {
<-start
_, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, Roles: []string{"buyer", "billing"}, ExpectedBindingIDs: ids})
results <- err
}()
}
close(start)
success, conflict := 0, 0
for range 2 {
err := <-results
switch {
case err == nil:
success++
case errors.Is(err, access.ErrRoleChangeConflict):
conflict++
default:
t.Fatalf("concurrent error=%v", err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("success=%d conflict=%d", success, conflict)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v", roles)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1)
}
func TestRoleSetRollsBackRevocationAndPartialInsert(t *testing.T) {
for _, stage := range []string{"second binding", "audit", "missing role"} {
t.Run(stage, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
before, _ := f.bindings(t, roleMember)
var statement string
switch stage {
case "second binding":
statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'write failure'); END`
case "audit":
statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='access.role.replace' BEGIN SELECT RAISE(ABORT,'audit failure'); END`
case "missing role":
statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'`
}
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
if err := f.change(t, roleOwner, roleMember, "billing", "buyer"); err == nil {
t.Fatal("write failure accepted")
}
after, roles := f.bindings(t, roleMember)
if !slices.Equal(before, after) || !slices.Equal(roles, []string{"member"}) {
t.Fatalf("after=%v roles=%v", after, roles)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 0)
})
}
}
func TestRoleInvitationPreservesRolesAuthorityAndSingleUse(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation := f.invite(t, "buyer", "billing")
stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now)
if err != nil {
t.Fatal(err)
}
if stored.RequiredOwnerRole != "customer.owner" || stored.DirectRole != "" || !slices.Equal(stored.DirectRoles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v authority=%q", stored.DirectRoles, stored.RequiredOwnerRole)
}
listed, err := f.organizations.Invitations(t.Context(), f.org.ID, 10)
if err != nil || len(listed) != 1 || !slices.Equal(listed[0].DirectRoles, stored.DirectRoles) {
t.Fatalf("listed=%v err=%v", listed, err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.create' AND request_id='request-invite'`, invitation.ID, 1)
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleOwner); err == nil {
t.Fatal("wrong email accepted")
}
start := make(chan struct{})
results := make(chan error, 2)
for range 2 {
go func() { <-start; results <- f.organizations.AcceptInvitation(t.Context(), raw, roleMember) }()
}
close(start)
success := 0
for range 2 {
if err := <-results; err == nil {
success++
} else if !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("accept error=%v", err)
}
}
if success != 1 {
t.Fatalf("accepted=%d", success)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v", roles)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 1)
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("replay=%v", err)
}
}
func TestRoleInvitationRechecksGrantorAndRecipient(t *testing.T) {
for _, mutation := range []string{
`UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id='customer-12345'`,
`UPDATE gwf_organization_memberships SET status='suspended' WHERE user_id='customer-12345'`,
`UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`,
`UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`,
`DELETE FROM gwf_organization_memberships WHERE user_id='customer-12345'`,
`UPDATE gwf_users SET status='disabled' WHERE id='member-12345678'`,
`UPDATE gwf_users SET registration_pending=1 WHERE id='member-12345678'`,
`UPDATE gwf_organizations SET status='archived'`,
`UPDATE gwf_organization_invitations SET expires_at=2100`,
`UPDATE gwf_organization_invitations SET revoked_at=2100`,
`UPDATE gwf_organization_invitations SET direct_roles_json='["buyer","buyer"]'`,
} {
t.Run(mutation, func(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation := f.invite(t, "buyer", "billing")
if _, err := f.store.db.Exec(mutation); err != nil {
t.Fatal(err)
}
// Stored authority still applies through a differently configured service.
other, err := organizations.New(f.store, organizations.Options{Now: func() time.Time { return f.now }})
if err != nil {
t.Fatal(err)
}
if err = other.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
t.Fatal("stale or invalid authority accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0)
})
}
}
func TestRoleInvitationRejectsImplicitReactivationAndNonOwnerManagement(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
if _, _, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "new@example.test", InvitedByUserID: roleMember, DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("member invite=%v", err)
}
raw, invitation := f.invite(t, "buyer", "billing")
if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleMember, "request-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("member revoke=%v", err)
}
if err := f.organizations.SetMembershipStatus(t.Context(), f.org.ID, roleMember, "suspended", roleOwner, "request-suspend"); err != nil {
t.Fatal(err)
}
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
t.Fatal("invitation reactivated suspended member")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='suspended'`, roleMember, 1)
if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleOwner, "request-revoke-owner"); err != nil {
t.Fatal(err)
}
}
func TestRoleInvitationAcceptanceRollsBackEveryWrite(t *testing.T) {
for _, stage := range []string{"membership", "binding", "audit", "missing role"} {
t.Run(stage, func(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation := f.invite(t, "buyer", "billing")
var statement string
switch stage {
case "membership":
statement = `CREATE TRIGGER fail_member BEFORE INSERT ON gwf_organization_memberships BEGIN SELECT RAISE(ABORT,'membership failure'); END`
case "binding":
statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'binding failure'); END`
case "audit":
statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.accept' BEGIN SELECT RAISE(ABORT,'audit failure'); END`
case "missing role":
statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'`
}
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
t.Fatal("partial acceptance succeeded")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 0)
})
}
}
func TestRoleInvitationMigrationPreservesLegacyAndRequiresExplicitMigration(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRole: "customer.owner", Lifetime: time.Hour})
if err != nil {
t.Fatal(err)
}
// Reconstruct the previous invitation schema in this disposable database.
if _, err = f.store.db.Exec(`ALTER TABLE gwf_organization_invitations DROP COLUMN direct_roles_json;
ALTER TABLE gwf_organization_invitations DROP COLUMN required_owner_role;
ALTER TABLE gwf_users DROP COLUMN profile_revision;
DELETE FROM gamertan_web_migrations WHERE version>=10`); err != nil {
t.Fatal(err)
}
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
t.Fatal("old schema accepted without migration")
}
for range 2 {
if err = f.store.Migrate(t.Context()); err != nil {
t.Fatal(err)
}
}
if err = f.store.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal(err)
}
stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now)
if err != nil || stored.DirectRole != "customer.owner" || len(stored.DirectRoles) != 0 || stored.RequiredOwnerRole != "" {
t.Fatalf("legacy changed: %+v err=%v", stored, err)
}
if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id=?`, roleOwner); err != nil {
t.Fatal(err)
}
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("legacy owner authority=%v", err)
}
if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=NULL WHERE subject_id=?`, roleOwner); err != nil {
t.Fatal(err)
}
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
t.Fatal(err)
}
ids, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"customer.owner"}) || !slices.Equal(ids, []string{"invite-" + invitation.ID}) {
t.Fatalf("legacy IDs=%v roles=%v", ids, roles)
}
}
func TestRoleInvitationUsesAdvancingClockAndBoundAudit(t *testing.T) {
f := newRoleSetFixture(t)
service, err := organizations.New(f.store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true})
if err != nil {
t.Fatal(err)
}
raw, invitation, err := service.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: []string{"billing", "buyer"}, Lifetime: time.Hour})
if err != nil {
t.Fatal(err)
}
audit := organizations.AuditEvent{ID: "audit-accept-12345", OrganizationID: f.org.ID, ActorUserID: roleMember, Action: "invitation.accept", ResourceType: "invitation", ResourceID: invitation.ID, Summary: "Invitation accepted", CreatedAt: time.Now().UTC()}
for _, field := range []string{"actor", "resource", "action"} {
bad := audit
switch field {
case "actor":
bad.ActorUserID = roleOwner
case "resource":
bad.ResourceID = "invitation-other"
case "action":
bad.Action = "invitation.create"
}
if err = f.store.AcceptInvitationWithRoles(t.Context(), invitation.Digest, roleMember, "customer.owner", time.Now().UTC(), bad); err == nil {
t.Fatalf("mismatched audit %s accepted", field)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
}
if err = service.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
t.Fatalf("real clock acceptance=%v", err)
}
}
func TestRoleInvitationCreationIsAtomicAndRejectsUnknownRole(t *testing.T) {
for _, fail := range []string{"unknown role", "audit"} {
t.Run(fail, func(t *testing.T) {
f := newRoleSetFixture(t)
roles := []string{"billing", "buyer"}
if fail == "unknown role" {
roles = append(roles, "unknown")
} else if _, err := f.store.db.Exec(`CREATE TRIGGER fail_invite BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.create' BEGIN SELECT RAISE(ABORT,'audit failure'); END`); err != nil {
t.Fatal(err)
}
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: roles, Lifetime: time.Hour})
if err == nil || raw != "" || invitation.ID != "" {
t.Fatal("failed creation returned invitation")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE organization_id=?`, f.org.ID, 0)
})
}
}
func TestRoleInvitationCannotBypassMembershipChanges(t *testing.T) {
for _, optimistic := range []bool{false, true} {
t.Run(map[bool]string{false: "legacy removal", true: "optimistic removal"}[optimistic], func(t *testing.T) {
f := newRoleSetFixture(t)
oldToken, oldInvitation := f.invite(t, "buyer", "billing")
f.addMember(t)
if err := f.organizations.AcceptInvitation(t.Context(), oldToken, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("old invite elevated existing member=%v", err)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"member"}) {
t.Fatalf("roles changed=%v", roles)
}
remove := func() error {
if optimistic {
return f.organizations.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, ExpectedStatus: "active", RequestID: "request-remove"})
}
return f.organizations.RemoveMembership(t.Context(), f.org.ID, roleMember, roleOwner, "request-remove")
}
if _, err := f.store.db.Exec(`CREATE TRIGGER fail_removal BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='membership.remove' BEGIN SELECT RAISE(ABORT,'audit failure'); END`); err != nil {
t.Fatal(err)
}
if err := remove(); err == nil {
t.Fatal("unaudited removal succeeded")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NULL AND used_at IS NULL`, oldInvitation.ID, 1)
if _, err := f.store.db.Exec(`DROP TRIGGER fail_removal`); err != nil {
t.Fatal(err)
}
if err := remove(); err != nil {
t.Fatal(err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NOT NULL AND used_at IS NULL`, oldInvitation.ID, 1)
if err := f.organizations.AcceptInvitation(t.Context(), oldToken, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("old invite restored removed member=%v", err)
}
newToken, _ := f.invite(t, "buyer")
if err := f.organizations.AcceptInvitation(t.Context(), newToken, roleMember); err != nil {
t.Fatalf("intentional fresh invitation=%v", err)
}
_, roles = f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"buyer"}) {
t.Fatalf("fresh roles=%v", roles)
}
})
}
}
+15 -1
View File
@@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
return store, nil
}
const SchemaVersion = 8
const SchemaVersion = 11
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)`,
@@ -177,6 +179,7 @@ func (store *Store) Migrate(ctx context.Context) error {
table, column, definition string
}{
{"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`},
{"gwf_users", "profile_revision", `INTEGER NOT NULL DEFAULT 1 CHECK(profile_revision > 0)`},
{"gwf_organizations", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`},
{"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
{"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 0`},
@@ -187,6 +190,8 @@ func (store *Store) Migrate(ctx context.Context) error {
{"gwf_organization_invitations", "revoked_at", `INTEGER`},
{"gwf_organization_invitations", "direct_role", `TEXT NOT NULL DEFAULT ''`},
{"gwf_organization_invitations", "team_ids_json", `BLOB NOT NULL DEFAULT '[]'`},
{"gwf_organization_invitations", "direct_roles_json", `BLOB NOT NULL DEFAULT '[]'`},
{"gwf_organization_invitations", "required_owner_role", `TEXT NOT NULL DEFAULT ''`},
} {
exists, columnErr := sqliteColumnExists(ctx, tx, migration.table, migration.column)
if columnErr != nil {
@@ -234,6 +239,15 @@ 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
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(10,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(11,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit()
}
+368 -3
View File
@@ -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,368 @@ 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)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: member.ID, RequestID: "request-self-promote", ExpectedBindingIDs: []string{memberBinding.ID}}); !errors.Is(err, access.ErrOwnerAuthority) {
t.Fatalf("non-owner self-promotion err=%v", err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-demote-owner", ExpectedBindingIDs: []string{ownerBinding.ID}}); !errors.Is(err, access.ErrOwnerAuthority) {
t.Fatalf("non-owner owner-demotion err=%v", err)
}
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: member.ID, RequestID: "request-suspend-owner-without-authority"}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner-suspension err=%v", err)
}
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", ActorUserID: member.ID, RequestID: "request-remove-owner-without-authority"}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner-removal err=%v", err)
}
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", member.ID, "request-legacy-suspend-owner-without-authority"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("legacy non-owner owner-suspension err=%v", err)
}
if err = organizationService.RemoveMembership(t.Context(), organization.ID, owner.ID, member.ID, "request-legacy-remove-owner-without-authority"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("legacy non-owner owner-removal err=%v", 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 TestOwnerInvitationsRequireDirectOwnerAuthority(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, 12, 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: "invitation.owner", Email: "invitation-owner@example.test", DisplayName: "Invitation Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
manager, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "invitation.manager", Email: "invitation-manager@example.test", DisplayName: "Invitation Manager", 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: "invitation-authority", Name: "Invitation Authority", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, _, err := organizationService.Invite(t.Context(), organization.ID, manager.Email, owner.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, manager.ID); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"owner": "Owner", "site-admin": "Site administrator", "viewer": "Viewer"},
Permissions: map[string]string{"site.access.manage": "Manage site access"},
Grants: map[string][]string{"owner": {"site.access.manage"}, "site-admin": {"site.access.manage"}, "viewer": {}},
}
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)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: manager.ID, Role: "site-admin", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if _, _, err = organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "blocked-owner@example.test", InvitedByUserID: manager.ID, DirectRole: "owner", Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner invitation err=%v", err)
}
_, viewerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "viewer@example.test", InvitedByUserID: manager.ID, DirectRole: "viewer", Lifetime: time.Hour})
if err != nil {
t.Fatalf("non-owner ordinary invitation err=%v", err)
}
_, ownerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "new-owner@example.test", InvitedByUserID: owner.ID, DirectRole: "owner", Lifetime: time.Hour})
if err != nil {
t.Fatalf("owner invitation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, manager.ID, "request-manager-owner-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner invitation revocation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, viewerInvitation.ID, manager.ID, "request-manager-viewer-revoke"); err != nil {
t.Fatalf("ordinary invitation revocation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, owner.ID, "request-owner-owner-revoke"); err != nil {
t.Fatalf("owner invitation revocation err=%v", err)
}
}
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
}
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn
import (
"crypto/ecdh"
"crypto/rand"
"errors"
"testing"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
)
func TestEnforceCredentialAlgorithmUsesVerifiedCOSEKey(t *testing.T) {
privateKey, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
publicKey := privateKey.PublicKey().Bytes()
encoded, err := webauthncbor.Marshal(map[int64]any{
1: int64(webauthncose.EllipticKey),
3: int64(webauthncose.AlgES256),
-1: int64(webauthncose.P256),
-2: publicKey[1:33],
-3: publicKey[33:65],
})
if err != nil {
t.Fatal(err)
}
credential := &wa.Credential{
PublicKey: encoded,
// This value is absent when a standards-compliant client serializes the
// mandatory attestation object without optional response conveniences.
Attestation: wa.CredentialAttestation{PublicKeyAlgorithm: 0},
}
if err = enforceCredentialAlgorithm(credential); err != nil {
t.Fatalf("verified ES256 COSE key rejected when convenience value was absent: %v", err)
}
}
func TestEnforceCredentialAlgorithmRejectsOtherOrInvalidKeys(t *testing.T) {
rsaKey, err := webauthncbor.Marshal(map[int64]any{
1: int64(webauthncose.RSAKey),
3: int64(webauthncose.AlgRS256),
-1: []byte{0xff},
-2: []byte{0x01, 0x00, 0x01},
})
if err != nil {
t.Fatal(err)
}
for name, credential := range map[string]*wa.Credential{
"nil": nil,
"malformed": {PublicKey: []byte("not-cose")},
"rsa": {PublicKey: rsaKey},
} {
t.Run(name, func(t *testing.T) {
if err := enforceCredentialAlgorithm(credential); !errors.Is(err, ErrUnsupportedCredential) {
t.Fatalf("error=%v", err)
}
})
}
}
+95 -10
View File
@@ -12,8 +12,10 @@ import (
"errors"
"fmt"
"io"
"net"
"net/url"
"regexp"
"strconv"
"strings"
"time"
@@ -36,9 +38,14 @@ const (
var accountNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
type Config struct {
RPID string
RPDisplayName string
Origin string
RPID string
RPDisplayName string
Origin string
// AllowDevelopmentPort permits an explicit non-default HTTPS port only
// for localhost or a reserved .test relying-party ID. Production origins
// remain portless, while local applications can terminate trusted HTTPS
// without requiring a privileged listener.
AllowDevelopmentPort bool
EnrollmentLifetime time.Duration
RegistrationTTL time.Duration
LoginTTL time.Duration
@@ -66,7 +73,7 @@ func New(repository Repository, authService *auth.Service, config Config) (*Serv
if repository == nil || authService == nil {
return nil, errors.New("authwebauthn: repository and auth service are required")
}
if err := validateOrigin(config.RPID, config.Origin); err != nil {
if err := validateOrigin(config.RPID, config.Origin, config.AllowDevelopmentPort); err != nil {
return nil, err
}
if strings.TrimSpace(config.RPDisplayName) == "" || len(config.RPDisplayName) > 80 {
@@ -218,6 +225,23 @@ func (service *Service) BeginAccountRegistration(ctx context.Context, userID, la
return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), true)
}
// BeginRecoveryRegistration starts a replacement-passkey ceremony bound to a
// short-lived recovery grant selected by the application. The grant itself is
// never persisted in ceremony state; only its digest is retained.
func (service *Service) BeginRecoveryRegistration(ctx context.Context, userID, label string, binding []byte) (BeginResult, error) {
if len(binding) < 16 || len(binding) > 4096 {
return BeginResult{}, ErrOperationBinding
}
user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID))
if err != nil {
return BeginResult{}, err
}
if user.RegistrationPending || user.Status != "active" {
return BeginResult{}, auth.ErrInactiveUser
}
return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), false)
}
// BeginPasswordMigration starts registration for an already authenticated
// password-backed user. Completion atomically retires the password and revokes
// all sessions, including the session that authorized this ceremony.
@@ -262,7 +286,19 @@ func (service *Service) beginRegistration(ctx context.Context, user auth.User, l
}
func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, [32]byte{}, response, false, false, nil)
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", [32]byte{}, response, false, false, nil)
}
// FinishRegistrationForUser verifies an ordinary self-service enrollment only
// when the ceremony belongs to the authenticated user selected by the
// application. The ceremony is consumed on mismatch so a leaked token cannot
// be retried through another account session.
func (service *Service) FinishRegistrationForUser(ctx context.Context, ceremonyToken, expectedUserID string, response []byte) (Credential, error) {
expectedUserID = strings.TrimSpace(expectedUserID)
if expectedUserID == "" {
return Credential{}, ErrOperationBinding
}
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, expectedUserID, [32]byte{}, response, false, false, nil)
}
// FinishAccountRegistration verifies an initial credential and delegates its
@@ -274,7 +310,21 @@ func (service *Service) FinishAccountRegistration(ctx context.Context, ceremonyT
if len(binding) < 16 || len(binding) > 4096 || commit == nil {
return Credential{}, ErrOperationBinding
}
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, BindingDigest(binding), response, false, true, commit)
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, true, commit)
}
// FinishRecoveryRegistration verifies a replacement passkey and delegates its
// persistence to commit so recovery-grant consumption, credential storage, and
// recovery-code replacement can share one transaction.
func (service *Service) FinishRecoveryRegistration(ctx context.Context, ceremonyToken string, binding, response []byte, commit RegistrationCommit) (Credential, error) {
if len(binding) < 16 || len(binding) > 4096 || commit == nil {
return Credential{}, ErrOperationBinding
}
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, false, func(commitContext context.Context, credential Credential, audit auth.AuditEvent) error {
audit.Action = "auth.recovery.passkey"
audit.Summary = "A replacement passkey was enrolled during account recovery."
return commit(commitContext, credential, audit)
})
}
// FinishPasswordMigration verifies the new passkey and persists it together
@@ -287,11 +337,14 @@ func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyTok
return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true, false, nil)
}
func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind string, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind, expectedUserID string, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, kind)
if err != nil {
return Credential{}, err
}
if expectedUserID != "" && ceremony.UserID != expectedUserID {
return Credential{}, ErrOperationBinding
}
return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword, allowPending, commit)
}
@@ -322,7 +375,7 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
if err != nil {
return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err)
}
if verified.Attestation.PublicKeyAlgorithm != int64(webauthncose.AlgES256) {
if err = enforceCredentialAlgorithm(verified); err != nil {
return Credential{}, ErrUnsupportedCredential
}
encoded, err := json.Marshal(verified)
@@ -354,6 +407,26 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
return record, nil
}
// enforceCredentialAlgorithm derives the algorithm from the verified COSE key
// carried inside authenticator data. AuthenticatorAttestationResponse's
// publicKeyAlgorithm member is an optional browser convenience value: clients
// that serialize the mandatory attestation object directly may omit it, and it
// is not the cryptographically authoritative representation.
func enforceCredentialAlgorithm(credential *wa.Credential) error {
if credential == nil {
return ErrUnsupportedCredential
}
parsed, err := webauthncose.ParsePublicKey(credential.PublicKey)
if err != nil {
return ErrUnsupportedCredential
}
key, ok := parsed.(webauthncose.EC2PublicKeyData)
if !ok || key.Algorithm != int64(webauthncose.AlgES256) {
return ErrUnsupportedCredential
}
return nil
}
func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) {
challenge, err := service.randomBytes(32)
if err != nil {
@@ -679,12 +752,24 @@ func passwordMigrationBinding(userID string) [32]byte {
return BindingDigest([]byte("gamertan-web/password-to-passkey/v1\x00" + userID))
}
func validateOrigin(rpID, rawOrigin string) error {
func validateOrigin(rpID, rawOrigin string, allowDevelopmentPort bool) error {
if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" {
return errors.New("authwebauthn: relying-party ID and origin are required")
}
origin, err := url.Parse(rawOrigin)
if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.Port() != "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
port := origin.Port()
if port == "" {
if origin.Host != rpID {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
return nil
}
developmentRP := rpID == "localhost" || strings.HasSuffix(rpID, ".test")
value, portErr := strconv.ParseUint(port, 10, 16)
if !allowDevelopmentPort || !developmentRP || portErr != nil || value == 0 || value == 443 || strconv.FormatUint(value, 10) != port || origin.Host != net.JoinHostPort(rpID, port) {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
return nil
+45
View File
@@ -4,6 +4,7 @@ package authwebauthn_test
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
@@ -53,6 +54,12 @@ func TestBootstrapEnrollmentAndApprovalPolicy(t *testing.T) {
if !begin.ExpiresAt.Equal(now.Add(5 * time.Minute)) {
t.Fatalf("registration expiry=%v", begin.ExpiresAt)
}
if _, err = service.FinishRegistrationForUser(t.Context(), begin.CeremonyToken, "another-user", []byte(`{}`)); !errors.Is(err, authwebauthn.ErrOperationBinding) {
t.Fatalf("cross-account registration completion err=%v", err)
}
if _, err = service.FinishRegistrationForUser(t.Context(), begin.CeremonyToken, user.ID, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
t.Fatalf("mismatched completion did not consume ceremony: %v", err)
}
if err = service.RequireReady(t.Context(), user.ID); !errors.Is(err, authwebauthn.ErrPasskeyReadiness) {
t.Fatalf("readiness without credentials err=%v", err)
@@ -141,6 +148,30 @@ func TestRecoveryRevokesSessionsAndIssuesSingleUseEnrollment(t *testing.T) {
}
}
func TestRecoveryRegistrationIsBoundAndConsumesMismatchedCeremony(t *testing.T) {
now := time.Date(2026, 9, 3, 13, 0, 0, 0, time.UTC)
store, authService, service := newService(t, &now, &counterReader{})
defer store.Close()
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.bound", Email: "recover-bound@example.test", DisplayName: "Recover Bound", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
binding := bytes.Repeat([]byte("restricted recovery grant "), 2)
begin, err := service.BeginRecoveryRegistration(t.Context(), user.ID, "Replacement passkey", binding)
if err != nil {
t.Fatal(err)
}
if _, err = service.FinishRecoveryRegistration(t.Context(), begin.CeremonyToken, append([]byte(nil), binding[:len(binding)-1]...), []byte(`{}`), func(context.Context, authwebauthn.Credential, auth.AuditEvent) error { return nil }); !errors.Is(err, authwebauthn.ErrOperationBinding) {
t.Fatalf("tampered recovery binding err=%v", err)
}
if _, err = service.FinishRecoveryRegistration(t.Context(), begin.CeremonyToken, binding, []byte(`{}`), func(context.Context, authwebauthn.Credential, auth.AuditEvent) error { return nil }); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
t.Fatalf("mismatched completion did not consume recovery ceremony: %v", err)
}
if _, err = service.BeginRecoveryRegistration(t.Context(), user.ID, "Replacement passkey", []byte("short")); !errors.Is(err, authwebauthn.ErrOperationBinding) {
t.Fatalf("short recovery binding err=%v", err)
}
}
func TestPasswordMigrationCeremonyIsBoundAndUnavailableAfterRetirement(t *testing.T) {
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
@@ -200,11 +231,25 @@ func TestConfigurationAndEntropyFailures(t *testing.T) {
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "http://tend.gamertan.com"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://other.gamertan.com"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com/path"},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:8443"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com:8443", AllowDevelopmentPort: true},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:443", AllowDevelopmentPort: true},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:08443", AllowDevelopmentPort: true},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:0", AllowDevelopmentPort: true},
} {
if _, err = authwebauthn.New(store, authService, config); err == nil {
t.Fatalf("accepted config=%+v", config)
}
}
for _, config := range []authwebauthn.Config{
{RPID: "localhost", RPDisplayName: "Tend Local", Origin: "https://localhost:8443", AllowDevelopmentPort: true},
{RPID: "tend.test", RPDisplayName: "Tend Local", Origin: "https://tend.test:8443", AllowDevelopmentPort: true},
} {
configured, configureErr := authwebauthn.New(store, authService, config)
if configureErr != nil || configured == nil {
t.Fatalf("development config=%+v service=%v err=%v", config, configured, configureErr)
}
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: failingReader{}})
if err != nil {
t.Fatal(err)
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: MPL-2.0
// Package bootstrap creates the first application owner and non-personal
// organization as one storage transaction. It is intended for a root-local
// operator command, not for public registration or a network administration
// endpoint.
package bootstrap
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"net/mail"
"regexp"
"strings"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
"gamertan.com/web/organizations"
)
const defaultEnrollmentLifetime = 15 * time.Minute
var (
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
rolePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
)
// Input is the reviewed, non-secret identity and organization metadata from a
// local operator command.
type Input struct {
Username string
Email string
DisplayName string
OrganizationSlug string
OrganizationName string
}
// Setup is the complete secret-free state a repository must commit atomically.
// Enrollment contains only a digest; the raw token remains in the Result.
type Setup struct {
User auth.User
Enrollment authwebauthn.EnrollmentToken
Organization organizations.Organization
Membership organizations.Membership
OwnerBinding access.Binding
AuthAudit auth.AuditEvent
OrganizationAudit organizations.AuditEvent
AccessAudit access.AuditEvent
}
// Result contains the created public records and the one-time enrollment
// secret. Applications must deliver EnrollmentToken through a private channel
// and must never log it.
type Result struct {
User auth.User
Organization organizations.Organization
EnrollmentToken string
ExpiresAt time.Time
}
// Repository owns the single transaction spanning identity, enrollment,
// organization membership, owner access, and their audit events.
type Repository interface {
CreateInitialOwner(context.Context, Setup) error
}
type Options struct {
OwnerRole string
EnrollmentLifetime time.Duration
Random io.Reader
Now func() time.Time
}
type Service struct {
repository Repository
ownerRole string
enrollmentLifetime time.Duration
random io.Reader
now func() time.Time
}
func New(repository Repository, options Options) (*Service, error) {
if repository == nil {
return nil, errors.New("bootstrap: repository is required")
}
if !rolePattern.MatchString(options.OwnerRole) {
return nil, errors.New("bootstrap: owner role is invalid")
}
if options.EnrollmentLifetime == 0 {
options.EnrollmentLifetime = defaultEnrollmentLifetime
}
if options.EnrollmentLifetime < time.Minute || options.EnrollmentLifetime > time.Hour {
return nil, errors.New("bootstrap: enrollment lifetime is invalid")
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
return &Service{repository: repository, ownerRole: options.OwnerRole, enrollmentLifetime: options.EnrollmentLifetime, random: options.Random, now: options.Now}, nil
}
// Start atomically creates one active passkey-only owner, one active
// non-personal organization, direct owner access, and a single-use enrollment
// token. It does not create a session or expose a network bootstrap surface.
func (service *Service) Start(ctx context.Context, input Input) (Result, error) {
input.Username = strings.TrimSpace(input.Username)
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.OrganizationSlug = strings.ToLower(strings.TrimSpace(input.OrganizationSlug))
input.OrganizationName = strings.TrimSpace(input.OrganizationName)
if !identifierPattern.MatchString(input.Username) || !canonicalEmail(input.Email) || !bounded(input.DisplayName, 128) || !slugPattern.MatchString(input.OrganizationSlug) || !bounded(input.OrganizationName, 128) {
return Result{}, errors.New("bootstrap: invalid owner or organization")
}
values, err := service.randomValues(7)
if err != nil {
return Result{}, err
}
now := service.now().UTC()
userID, organizationID, bindingID := values[0], values[1], values[2]
rawToken := values[3]
user := auth.User{ID: userID, Username: input.Username, Email: input.Email, DisplayName: input.DisplayName, Status: "active", CreatedAt: now, UpdatedAt: now}
organization := organizations.Organization{ID: organizationID, Slug: input.OrganizationSlug, Name: input.OrganizationName, Status: "active", Revision: 1, CreatedAt: now, UpdatedAt: now}
enrollment := authwebauthn.EnrollmentToken{Digest: sha256.Sum256([]byte(rawToken)), UserID: userID, CreatedAt: now, ExpiresAt: now.Add(service.enrollmentLifetime)}
membership := organizations.Membership{OrganizationID: organizationID, UserID: userID, Status: "active", JoinedAt: now}
binding := access.Binding{ID: bindingID, SubjectKind: access.User, SubjectID: userID, Role: service.ownerRole, Scope: access.Scope{OrganizationID: organizationID}, GrantedBy: userID, GrantedAt: now}
setup := Setup{
User: user,
Enrollment: enrollment,
Organization: organization,
Membership: membership,
OwnerBinding: binding,
AuthAudit: auth.AuditEvent{ID: values[4], ActorUserID: userID, Action: "auth.passkey.bootstrap", ResourceType: "user", ResourceID: userID, Summary: "A local operator created the initial passkey-only owner and one-time enrollment token.", CreatedAt: now},
OrganizationAudit: organizations.AuditEvent{ID: values[5], OrganizationID: organizationID, ActorUserID: userID, Action: "organization.bootstrap", ResourceType: "organization", ResourceID: organizationID, Summary: "A local operator created the initial organization.", CreatedAt: now},
AccessAudit: access.AuditEvent{ID: values[6], OrganizationID: organizationID, ActorUserID: userID, Action: "access.binding.grant", ResourceType: "binding", ResourceID: bindingID, Summary: "The initial owner received direct organization access.", CreatedAt: now},
}
if err = service.repository.CreateInitialOwner(ctx, setup); err != nil {
return Result{}, err
}
return Result{User: user, Organization: organization, EnrollmentToken: rawToken, ExpiresAt: enrollment.ExpiresAt}, nil
}
func (service *Service) randomValues(count int) ([]string, error) {
values := make([]string, count)
for index := range values {
bytes := make([]byte, 24)
if _, err := io.ReadFull(service.random, bytes); err != nil {
return nil, fmt.Errorf("bootstrap: secure randomness unavailable: %w", err)
}
values[index] = base64.RawURLEncoding.EncodeToString(bytes)
}
return values, nil
}
func canonicalEmail(value string) bool {
if value == "" || len(value) > 320 || strings.ContainsAny(value, "\x00\r\n") {
return false
}
address, err := mail.ParseAddress(value)
return err == nil && address.Name == "" && address.Address == value
}
func bounded(value string, maximum int) bool {
return value != "" && len(value) <= maximum && !strings.ContainsAny(value, "\x00\r\n")
}
+78
View File
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: MPL-2.0
package bootstrap
import (
"context"
"errors"
"testing"
"time"
)
type recordingRepository struct {
setup Setup
err error
}
func (repository *recordingRepository) CreateInitialOwner(_ context.Context, setup Setup) error {
repository.setup = setup
return repository.err
}
func TestStartBuildsAtomicInitialOwnerSetup(t *testing.T) {
repository := new(recordingRepository)
now := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC)
service, err := New(repository, Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
result, err := service.Start(t.Context(), Input{Username: "cole.owner", Email: "COLE@EXAMPLE.TEST", DisplayName: "Cole Speelman", OrganizationSlug: "Gamertan", OrganizationName: "Gamertan"})
if err != nil {
t.Fatal(err)
}
setup := repository.setup
if result.EnrollmentToken == "" || setup.Enrollment.Digest == [32]byte{} || result.User.Email != "cole@example.test" || result.Organization.Personal || result.Organization.Status != "active" {
t.Fatalf("result=%+v setup=%+v", result, setup)
}
if setup.Membership.UserID != result.User.ID || setup.Membership.OrganizationID != result.Organization.ID || setup.OwnerBinding.Role != "home.owner" || setup.OwnerBinding.GrantedBy != result.User.ID {
t.Fatalf("membership=%+v binding=%+v", setup.Membership, setup.OwnerBinding)
}
if setup.AuthAudit.ID == setup.OrganizationAudit.ID || setup.OrganizationAudit.ID == setup.AccessAudit.ID || setup.AuthAudit.Summary == "" || setup.AccessAudit.ResourceID != setup.OwnerBinding.ID {
t.Fatalf("audits=%+v %+v %+v", setup.AuthAudit, setup.OrganizationAudit, setup.AccessAudit)
}
if !result.ExpiresAt.Equal(now.Add(15 * time.Minute)) {
t.Fatalf("expires=%v", result.ExpiresAt)
}
}
func TestStartRejectsUnsafeInputAndDoesNotCommit(t *testing.T) {
repository := new(recordingRepository)
service, err := New(repository, Options{OwnerRole: "home.owner"})
if err != nil {
t.Fatal(err)
}
for _, input := range []Input{
{Username: "x", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"},
{Username: "owner.user", Email: "Owner <owner@example.test>", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"},
{Username: "owner.user", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "bad/slug", OrganizationName: "Gamertan"},
} {
if _, startErr := service.Start(t.Context(), input); startErr == nil {
t.Fatalf("unsafe input accepted: %+v", input)
}
}
if repository.setup.User.ID != "" {
t.Fatal("repository was called for rejected input")
}
}
func TestStartDoesNotReturnSecretAfterRepositoryFailure(t *testing.T) {
repository := &recordingRepository{err: errors.New("commit failed")}
service, err := New(repository, Options{OwnerRole: "home.owner"})
if err != nil {
t.Fatal(err)
}
result, err := service.Start(t.Context(), Input{Username: "owner.user", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"})
if err == nil || result.EnrollmentToken != "" {
t.Fatalf("result=%+v err=%v", result, err)
}
}
+120
View File
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: MPL-2.0
// Package cms supplies bounded classification and editorial relationship values.
// It does not define content types, layouts, authorization, or commerce policy.
// Applications own those decisions and publish exact immutable revisions.
package cms
import (
"errors"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
var (
ErrInvalid = errors.New("cms: invalid value")
ErrConflict = errors.New("cms: revision or slug conflict")
ErrNotFound = errors.New("cms: not found")
identifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$`)
slug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
kind = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`)
)
const MaxTerms = 24
const MaxLinks = 16
// Reference names an application-owned resource, never a mutable URL or title.
type Reference struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
func ValidID(value string) bool { return identifier.MatchString(value) }
func ValidSlug(value string) bool { return len(value) <= 80 && slug.MatchString(value) }
func ValidText(value string, max int) bool {
return utf8.ValidString(value) && len(value) <= max && strings.TrimSpace(value) == value && !strings.ContainsFunc(value, unicode.IsControl)
}
func (r Reference) Validate() error {
if !kind.MatchString(r.Kind) || !ValidID(r.ID) {
return ErrInvalid
}
return nil
}
// Associations are an immutable revision's term memberships and explicit links.
// Reverse discovery reads the same links; callers must not store a second edge.
// Terms and Links are independently optional. The zero value is valid for a
// valid source; unclassified content never needs a placeholder taxonomy or link.
type Associations struct {
Terms []string `json:"terms,omitempty"`
Links []Reference `json:"links,omitempty"`
}
func (a Associations) Validate(source Reference) error {
if source.Validate() != nil || len(a.Terms) > MaxTerms || len(a.Links) > MaxLinks {
return ErrInvalid
}
terms := map[string]bool{}
for _, id := range a.Terms {
if !ValidID(id) || terms[id] {
return ErrInvalid
}
terms[id] = true
}
links := map[Reference]bool{}
for _, ref := range a.Links {
if ref.Validate() != nil || ref == source || links[ref] {
return ErrInvalid
}
links[ref] = true
}
return nil
}
// Taxonomy is an editor-named vocabulary, not a database-defined content type.
// Slug is fixed after creation; Name/Description and availability may change.
type Taxonomy struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Revision int64 `json:"revision"`
Active bool `json:"active"`
}
func (v Taxonomy) Validate() error {
if !ValidID(v.ID) || !ValidSlug(v.Slug) || v.Name == "" || !ValidText(v.Name, 120) || !ValidText(v.Description, 500) || v.Revision < 1 {
return ErrInvalid
}
return nil
}
// Term identity survives renaming and retirement. Retirement hides discovery;
// it does not rewrite old associations or imply removal of related resources.
type Term struct {
ID string `json:"id"`
TaxonomyID string `json:"taxonomy_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Revision int64 `json:"revision"`
Active bool `json:"active"`
}
func (v Term) Validate() error {
if !ValidID(v.ID) || !ValidID(v.TaxonomyID) || !ValidSlug(v.Slug) || v.Name == "" || !ValidText(v.Name, 120) || !ValidText(v.Description, 500) || v.Revision < 1 {
return ErrInvalid
}
return nil
}
type ResourceVersion struct {
Reference
Revision int64 `json:"revision"`
}
type Page struct {
Items []ResourceVersion `json:"items"`
Next *Reference `json:"next,omitempty"`
}
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: MPL-2.0
package cms
import (
"encoding/json"
"strings"
"testing"
)
func TestAssociationsBoundsAndIdentity(t *testing.T) {
source := Reference{Kind: "project", ID: "project-one"}
valid := Associations{Terms: []string{"go"}, Links: []Reference{{Kind: "news", ID: "launch"}}}
if valid.Validate(source) != nil {
t.Fatal("valid association rejected")
}
for _, value := range []Associations{
{Terms: []string{"go", "go"}}, {Terms: []string{"../private"}},
{Links: []Reference{source}}, {Links: []Reference{{Kind: "news", ID: "launch"}, {Kind: "news", ID: "launch"}}},
{Links: []Reference{{Kind: "<script>", ID: "safe"}}},
{Terms: make([]string, MaxTerms+1)}, {Links: make([]Reference, MaxLinks+1)},
} {
if value.Validate(source) == nil {
t.Fatalf("accepted %#v", value)
}
}
}
func TestAssociationsAreOptional(t *testing.T) {
for _, source := range []Reference{{Kind: "news", ID: "article"}, {Kind: "project", ID: "project"}, {Kind: "policy", ID: "terms"}} {
for _, raw := range []string{`{}`, `{"terms":null,"links":null}`, `{"terms":[],"links":[]}`, `{"terms":["go"]}`, `{"links":[{"kind":"project","id":"other"}]}`} {
var value Associations
if err := json.Unmarshal([]byte(raw), &value); err != nil {
t.Fatal(err)
}
if err := value.Validate(source); err != nil {
t.Fatalf("optional associations rejected: %s: %v", raw, err)
}
}
}
}
func TestTaxonomyAndTermText(t *testing.T) {
tax := Taxonomy{ID: "categories", Slug: "categories", Name: "Categories", Revision: 1, Active: true}
if tax.Validate() != nil {
t.Fatal("valid taxonomy")
}
for _, name := range []string{"", " bad", "bad\nname", string([]byte{255}), strings.Repeat("a", 121)} {
v := tax
v.Name = name
if v.Validate() == nil {
t.Fatal("accepted invalid name")
}
}
term := Term{ID: "go", TaxonomyID: tax.ID, Slug: "go", Name: "Go", Revision: 1, Active: true}
if term.Validate() != nil {
t.Fatal("valid term")
}
term.Slug = "../go"
if term.Validate() == nil {
t.Fatal("unsafe slug")
}
}
func FuzzAssociations(f *testing.F) {
f.Add(`{"terms":["go"],"links":[{"kind":"news","id":"launch"}]}`)
f.Fuzz(func(t *testing.T, raw string) {
if len(raw) > 20000 {
return
}
var value Associations
if json.Unmarshal([]byte(raw), &value) != nil {
return
}
if value.Validate(Reference{Kind: "project", ID: "one"}) == nil {
b, e := json.Marshal(value)
if e != nil {
t.Fatal(e)
}
var again Associations
if json.Unmarshal(b, &again) != nil || again.Validate(Reference{Kind: "project", ID: "one"}) != nil {
t.Fatal("round trip")
}
}
})
}
+168
View File
@@ -0,0 +1,168 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite
import (
"context"
"database/sql"
"encoding/json"
"gamertan.com/web/cms"
)
// PutRevision appends once. Unknown terms are rejected; retired terms remain
// usable when copying historical revisions. Editors decide whether to admit new
// retired-term assignments. Link targets are validated by the application: they
// can live in a different content/catalog store. A link grants no authority.
func PutRevision(ctx context.Context, tx *sql.Tx, scope string, ref cms.Reference, revision int64, a cms.Associations) error {
if !cms.ValidID(scope) || revision < 1 || a.Validate(ref) != nil {
return cms.ErrInvalid
}
for _, id := range a.Terms {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_terms WHERE scope=? AND id=?`, scope, id).Scan(&count); err != nil {
return err
}
if count != 1 {
return cms.ErrNotFound
}
}
b, err := json.Marshal(a)
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_resources(scope,kind,id) VALUES(?,?,?) ON CONFLICT DO NOTHING`, scope, ref.Kind, ref.ID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_cms_associations(scope,kind,id,revision,document) VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, ref.Kind, ref.ID, revision, string(b))
if err = oneRow(result, err); err != nil {
return err
}
for _, id := range a.Terms {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_memberships(scope,kind,id,revision,term_id) VALUES(?,?,?,?,?)`, scope, ref.Kind, ref.ID, revision, id); err != nil {
return err
}
}
for _, target := range a.Links {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_links(scope,kind,id,revision,target_kind,target_id) VALUES(?,?,?,?,?,?)`, scope, ref.Kind, ref.ID, revision, target.Kind, target.ID); err != nil {
return err
}
}
return nil
}
// SetPublished selects an exact revision or zero to unpublish. Call inside the
// same transaction as the application's publication transition and audit. Draft
// saves do not call this function. Product availability is also checked by the
// consuming application; publication is not entitlement or payment authority.
func SetPublished(ctx context.Context, tx *sql.Tx, scope string, ref cms.Reference, revision int64) error {
if !cms.ValidID(scope) || ref.Validate() != nil || revision < 0 {
return cms.ErrInvalid
}
if revision > 0 {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=? AND revision=?`, scope, ref.Kind, ref.ID, revision).Scan(&count); err != nil {
return err
}
if count != 1 {
return cms.ErrNotFound
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_cms_resources SET published_revision=? WHERE scope=? AND kind=? AND id=?`, revision, scope, ref.Kind, ref.ID)
return oneRow(result, err)
}
func (r *Reader) Revision(ctx context.Context, ref cms.Reference, revision int64) (cms.Associations, error) {
if ref.Validate() != nil || revision < 1 {
return cms.Associations{}, cms.ErrInvalid
}
var raw string
err := r.db.QueryRowContext(ctx, `SELECT document FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=? AND revision=?`, r.scope, ref.Kind, ref.ID, revision).Scan(&raw)
if err != nil {
return cms.Associations{}, notFound(err)
}
var a cms.Associations
if len(raw) > 16384 || json.Unmarshal([]byte(raw), &a) != nil || a.Validate(ref) != nil {
return cms.Associations{}, cms.ErrInvalid
}
return a, nil
}
func (r *Reader) LatestRevision(ctx context.Context, ref cms.Reference) (int64, error) {
if ref.Validate() != nil {
return 0, cms.ErrInvalid
}
var revision sql.NullInt64
err := r.db.QueryRowContext(ctx, `SELECT MAX(revision) FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=?`, r.scope, ref.Kind, ref.ID).Scan(&revision)
if err != nil {
return 0, err
}
if !revision.Valid {
return 0, cms.ErrNotFound
}
return revision.Int64, nil
}
func (r *Reader) PublishedRevision(ctx context.Context, ref cms.Reference) (int64, error) {
if ref.Validate() != nil {
return 0, cms.ErrInvalid
}
var revision int64
err := r.db.QueryRowContext(ctx, `SELECT published_revision FROM gwf_cms_resources WHERE scope=? AND kind=? AND id=? AND published_revision>0`, r.scope, ref.Kind, ref.ID).Scan(&revision)
return revision, notFound(err)
}
// Members returns published members of an active term and taxonomy. Pagination
// happens after publication filtering, with stable kind/ID cursors.
func (r *Reader) Members(ctx context.Context, termID string, after *cms.Reference, limit int) (cms.Page, error) {
if !cms.ValidID(termID) {
return cms.Page{}, cms.ErrInvalid
}
query := `SELECT DISTINCT r.kind,r.id,r.published_revision FROM gwf_cms_memberships m JOIN gwf_cms_resources r ON r.scope=m.scope AND r.kind=m.kind AND r.id=m.id AND r.published_revision=m.revision JOIN gwf_cms_terms t ON t.scope=m.scope AND t.id=m.term_id JOIN gwf_cms_taxonomies x ON x.scope=t.scope AND x.id=t.taxonomy_id WHERE m.scope=? AND m.term_id=? AND r.published_revision>0 AND t.active=1 AND x.active=1`
return r.page(ctx, query, []any{r.scope, termID}, after, limit)
}
// Related returns both directions of explicit relationships between published
// revisions. Shared taxonomy membership alone does not assert a relationship.
func (r *Reader) Related(ctx context.Context, ref cms.Reference, after *cms.Reference, limit int) (cms.Page, error) {
if ref.Validate() != nil {
return cms.Page{}, cms.ErrInvalid
}
query := `WITH edges AS (
SELECT l.target_kind AS kind,l.target_id AS id FROM gwf_cms_links l JOIN gwf_cms_resources s ON s.scope=l.scope AND s.kind=l.kind AND s.id=l.id AND s.published_revision=l.revision WHERE l.scope=? AND l.kind=? AND l.id=? AND s.published_revision>0
UNION
SELECT l.kind,l.id FROM gwf_cms_links l JOIN gwf_cms_resources s ON s.scope=l.scope AND s.kind=l.kind AND s.id=l.id AND s.published_revision=l.revision WHERE l.scope=? AND l.target_kind=? AND l.target_id=? AND s.published_revision>0
) SELECT r.kind,r.id,r.published_revision FROM edges e JOIN gwf_cms_resources r ON r.kind=e.kind AND r.id=e.id WHERE r.scope=? AND r.published_revision>0 AND NOT(r.kind=? AND r.id=?) AND EXISTS(SELECT 1 FROM gwf_cms_resources origin WHERE origin.scope=? AND origin.kind=? AND origin.id=? AND origin.published_revision>0)`
return r.page(ctx, query, []any{r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID}, after, limit)
}
func (r *Reader) page(ctx context.Context, query string, args []any, after *cms.Reference, limit int) (cms.Page, error) {
if limit < 1 || limit > 100 || (after != nil && after.Validate() != nil) {
return cms.Page{}, cms.ErrInvalid
}
if after != nil {
query += ` AND (r.kind>? OR (r.kind=? AND r.id>?))`
args = append(args, after.Kind, after.Kind, after.ID)
}
query += ` ORDER BY r.kind,r.id LIMIT ?`
args = append(args, limit+1)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return cms.Page{}, err
}
defer rows.Close()
p := cms.Page{Items: []cms.ResourceVersion{}}
for rows.Next() {
var v cms.ResourceVersion
if err := rows.Scan(&v.Kind, &v.ID, &v.Revision); err != nil {
return cms.Page{}, err
}
p.Items = append(p.Items, v)
}
if err := rows.Err(); err != nil {
return cms.Page{}, err
}
if len(p.Items) > limit {
p.Items = p.Items[:limit]
ref := p.Items[limit-1].Reference
p.Next = &ref
}
return p, nil
}
+61
View File
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite_test
import (
"context"
"database/sql"
"fmt"
"gamertan.com/web/cms"
"gamertan.com/web/cmssqlite"
_ "modernc.org/sqlite"
)
func Example() {
ctx := context.Background()
db, err := sql.Open("sqlite", ":memory:?_pragma=foreign_keys(1)")
if err != nil {
panic(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
defer tx.Rollback()
if err = cmssqlite.CreateSchema(ctx, tx); err != nil {
panic(err)
}
const scope = "my-site"
if err = cmssqlite.PutTaxonomy(ctx, tx, scope, cms.Taxonomy{ID: "topics", Slug: "topics", Name: "Topics", Revision: 1, Active: true}, 0); err != nil {
panic(err)
}
if err = cmssqlite.PutTerm(ctx, tx, scope, cms.Term{ID: "go", TaxonomyID: "topics", Slug: "go", Name: "Go", Revision: 1, Active: true}, 0); err != nil {
panic(err)
}
ref := cms.Reference{Kind: "article", ID: "first-post"}
// The application authorizes the writer and stores its content/audit in this
// same transaction. Only publication advances the public association pointer.
if err = cmssqlite.PutRevision(ctx, tx, scope, ref, 1, cms.Associations{Terms: []string{"go"}}); err != nil {
panic(err)
}
if err = cmssqlite.SetPublished(ctx, tx, scope, ref, 1); err != nil {
panic(err)
}
if err = tx.Commit(); err != nil {
panic(err)
}
reader, err := cmssqlite.New(db, scope)
if err != nil {
panic(err)
}
page, err := reader.Members(ctx, "go", nil, 20)
if err != nil {
panic(err)
}
for _, item := range page.Items {
fmt.Println(item.Kind, item.ID, item.Revision)
}
// Output: article first-post 1
}
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: MPL-2.0
// Package cmssqlite stores cms values in an application's SQLite transaction.
// Schema installation is explicit. Callers own database opening, migration
// versions, authorization and audits. Every mutation must use a caller-owned
// transaction, committing its domain change and audit together; never use a
// pooled *sql.DB for multi-statement writes. Namespaces isolate application data.
package cmssqlite
import (
"context"
"database/sql"
"gamertan.com/web/cms"
)
type Queryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
// Reader may use a database or read transaction. Writers below require *sql.Tx.
type Reader struct {
db Queryer
scope string
}
func New(db Queryer, scope string) (*Reader, error) {
if db == nil || !cms.ValidID(scope) {
return nil, cms.ErrInvalid
}
return &Reader{db: db, scope: scope}, nil
}
// CreateSchema must be called from the application's explicit migration.
// It never changes an existing publishing schema or starts a transaction.
func CreateSchema(ctx context.Context, tx *sql.Tx) error {
for _, statement := range []string{
`CREATE TABLE IF NOT EXISTS gwf_cms_taxonomies (scope TEXT NOT NULL,id TEXT NOT NULL,slug TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),active INTEGER NOT NULL CHECK(active IN (0,1)),PRIMARY KEY(scope,id),UNIQUE(scope,slug))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_terms (scope TEXT NOT NULL,id TEXT NOT NULL,taxonomy_id TEXT NOT NULL,slug TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),active INTEGER NOT NULL CHECK(active IN (0,1)),PRIMARY KEY(scope,id),UNIQUE(scope,taxonomy_id,slug),FOREIGN KEY(scope,taxonomy_id) REFERENCES gwf_cms_taxonomies(scope,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_term_slugs (scope TEXT NOT NULL,taxonomy_id TEXT NOT NULL,slug TEXT NOT NULL,term_id TEXT NOT NULL,PRIMARY KEY(scope,taxonomy_id,slug),FOREIGN KEY(scope,term_id) REFERENCES gwf_cms_terms(scope,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_resources (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,published_revision INTEGER NOT NULL DEFAULT 0 CHECK(published_revision>=0),PRIMARY KEY(scope,kind,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_associations (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),document TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision),FOREIGN KEY(scope,kind,id) REFERENCES gwf_cms_resources(scope,kind,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_memberships (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL,term_id TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision,term_id),FOREIGN KEY(scope,kind,id,revision) REFERENCES gwf_cms_associations(scope,kind,id,revision),FOREIGN KEY(scope,term_id) REFERENCES gwf_cms_terms(scope,id))`,
`CREATE INDEX IF NOT EXISTS gwf_cms_memberships_term ON gwf_cms_memberships(scope,term_id,kind,id,revision)`,
`CREATE TABLE IF NOT EXISTS gwf_cms_links (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL,target_kind TEXT NOT NULL,target_id TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision,target_kind,target_id),FOREIGN KEY(scope,kind,id,revision) REFERENCES gwf_cms_associations(scope,kind,id,revision))`,
`CREATE INDEX IF NOT EXISTS gwf_cms_links_target ON gwf_cms_links(scope,target_kind,target_id,kind,id,revision)`,
} {
if _, err := tx.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
+239
View File
@@ -0,0 +1,239 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"testing"
"gamertan.com/web/cms"
_ "modernc.org/sqlite"
)
func fixture(t *testing.T) (*sql.DB, *Reader) {
t.Helper()
db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "cms.sqlite")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_txlock=immediate")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
mutate(t, db, nil, func(tx *sql.Tx) error { return CreateSchema(context.Background(), tx) })
r, e := New(db, "merchant")
if e != nil {
t.Fatal(e)
}
for _, scope := range []string{"merchant", "other"} {
mutate(t, db, nil, func(tx *sql.Tx) error {
return PutTaxonomy(context.Background(), tx, scope, cms.Taxonomy{ID: "topics", Slug: "topics", Name: "Topics", Active: true, Revision: 1}, 0)
})
}
mutate(t, db, nil, func(tx *sql.Tx) error {
return PutTerm(context.Background(), tx, "merchant", cms.Term{ID: "go", TaxonomyID: "topics", Slug: "go", Name: "Go", Active: true, Revision: 1}, 0)
})
return db, r
}
func mutate(t *testing.T, db *sql.DB, want error, fn func(*sql.Tx) error) {
t.Helper()
tx, e := db.BeginTx(context.Background(), nil)
if e != nil {
t.Fatal(e)
}
defer tx.Rollback()
err := fn(tx)
if !errors.Is(err, want) {
t.Fatalf("mutation error %v, want %v", err, want)
}
if err == nil {
if e = tx.Commit(); e != nil {
t.Fatal(e)
}
}
}
func save(t *testing.T, db *sql.DB, ref cms.Reference, rev int64, a cms.Associations, publish bool) {
t.Helper()
mutate(t, db, nil, func(tx *sql.Tx) error {
if err := PutRevision(context.Background(), tx, "merchant", ref, rev, a); err != nil {
return err
}
if publish {
return SetPublished(context.Background(), tx, "merchant", ref, rev)
}
return nil
})
}
func TestPublicationRelationshipsAndHistory(t *testing.T) {
db, r := fixture(t)
ctx := context.Background()
project := cms.Reference{Kind: "project", ID: "hime"}
news := cms.Reference{Kind: "news", ID: "launch"}
product := cms.Reference{Kind: "product", ID: "support"}
save(t, db, project, 1, cms.Associations{Terms: []string{"go"}}, true)
save(t, db, news, 1, cms.Associations{Terms: []string{"go"}, Links: []cms.Reference{project}}, false)
p, e := r.Related(ctx, project, nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatalf("draft leak: %+v %v", p, e)
}
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 1) })
for _, ref := range []cms.Reference{project, news} {
p, e = r.Related(ctx, ref, nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatalf("reverse missing: %+v %v", p, e)
}
}
save(t, db, product, 1, cms.Associations{}, true)
save(t, db, news, 2, cms.Associations{Links: []cms.Reference{product}}, false)
p, e = r.Related(ctx, project, nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatal("draft replaced published graph", e)
}
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 2) })
p, e = r.Related(ctx, project, nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("stale published edge", e)
}
p, e = r.Related(ctx, product, nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatal("missing new edge", e)
}
old, e := r.Revision(ctx, news, 1)
if e != nil || old.Links[0] != project {
t.Fatal("history changed", e)
}
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", product, 0) })
p, e = r.Related(ctx, news, nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("unpublished target leak", e)
}
p, e = r.Related(ctx, product, nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("unpublished source discovery", e)
}
// Restoring an old association is a new revision, not an overwritten row.
save(t, db, news, 3, old, true)
p, e = r.Related(ctx, project, nil, 10)
if e != nil || len(p.Items) != 1 || p.Items[0].Revision != 3 {
t.Fatal("restore", e)
}
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutRevision(ctx, tx, "merchant", news, 1, cms.Associations{}) })
mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 999) })
}
func TestPublishWithoutTaxonomyOrRelationships(t *testing.T) {
db, reader := fixture(t)
ctx := context.Background()
ref := cms.Reference{Kind: "project", ID: "unclassified"}
save(t, db, ref, 1, cms.Associations{}, true)
value, err := reader.Revision(ctx, ref, 1)
if err != nil || len(value.Terms) != 0 || len(value.Links) != 0 {
t.Fatalf("empty associations: %+v %v", value, err)
}
page, err := reader.Related(ctx, ref, nil, 10)
if err != nil || len(page.Items) != 0 {
t.Fatalf("unexpected related content: %+v %v", page, err)
}
save(t, db, ref, 2, cms.Associations{Terms: []string{"go"}}, true)
save(t, db, ref, 3, cms.Associations{}, true)
old, err := reader.Revision(ctx, ref, 2)
if err != nil || len(old.Terms) != 1 || old.Terms[0] != "go" {
t.Fatal("clearing optional classification rewrote history", err)
}
page, err = reader.Members(ctx, "go", nil, 10)
if err != nil || len(page.Items) != 0 {
t.Fatalf("cleared classification still published: %+v %v", page, err)
}
}
func TestTermRenameRetirementAndScope(t *testing.T) {
db, r := fixture(t)
ctx := context.Background()
ref := cms.Reference{Kind: "writing", ID: "essay"}
save(t, db, ref, 1, cms.Associations{Terms: []string{"go"}}, true)
term, e := r.Term(ctx, "go")
if e != nil {
t.Fatal(e)
}
term.Slug = "golang"
term.Name = "Go language"
term.Revision = 2
mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 1) })
alias, e := r.TermBySlug(ctx, "topics", "go")
if e != nil || alias.ID != term.ID || alias.Slug != "golang" {
t.Fatal("alias", e)
}
stolen := cms.Term{ID: "stolen", TaxonomyID: "topics", Slug: "go", Name: "Other", Revision: 1, Active: true}
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", stolen, 0) })
duplicate := term
duplicate.Revision = 1
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", duplicate, 0) })
other, _ := New(db, "other")
if _, e = other.Term(ctx, "go"); !errors.Is(e, cms.ErrNotFound) {
t.Fatal("cross scope term", e)
}
mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error {
return PutRevision(ctx, tx, "other", ref, 1, cms.Associations{Terms: []string{"go"}})
})
p, e := other.Members(ctx, "go", nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("scope leak", e)
}
p, e = r.Members(ctx, "go", nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatal("membership lost on rename", e)
}
term.Active = false
term.Revision = 3
mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 2) })
p, e = r.Members(ctx, "go", nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("retired term discovery", e)
}
old, e := r.Revision(ctx, ref, 1)
if e != nil || len(old.Terms) != 1 {
t.Fatal("retirement rewrote history", e)
}
save(t, db, ref, 2, old, true)
}
func TestPaginationFiltersDraftsBeforeLimitAndRollback(t *testing.T) {
db, r := fixture(t)
ctx := context.Background()
for i := 0; i < 35; i++ {
save(t, db, cms.Reference{Kind: "news", ID: fmt.Sprintf("news-%02d", i)}, 1, cms.Associations{Terms: []string{"go"}}, i >= 30)
}
var after *cms.Reference
var ids []string
for {
p, e := r.Members(ctx, "go", after, 2)
if e != nil {
t.Fatal(e)
}
for _, v := range p.Items {
ids = append(ids, v.ID)
}
if p.Next == nil {
break
}
after = p.Next
}
if len(ids) != 5 || ids[0] != "news-30" || ids[4] != "news-34" {
t.Fatal(ids)
}
tx, e := db.BeginTx(ctx, nil)
if e != nil {
t.Fatal(e)
}
ref := cms.Reference{Kind: "project", ID: "rollback"}
if e = PutRevision(ctx, tx, "merchant", ref, 1, cms.Associations{}); e != nil {
t.Fatal(e)
}
if e = SetPublished(ctx, tx, "merchant", ref, 1); e != nil {
t.Fatal(e)
}
if e = tx.Rollback(); e != nil {
t.Fatal(e)
}
if _, e = r.PublishedRevision(ctx, ref); !errors.Is(e, cms.ErrNotFound) {
t.Fatal("partial transaction", e)
}
}
+159
View File
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite
import (
"context"
"database/sql"
"errors"
"gamertan.com/web/cms"
)
func notFound(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return cms.ErrNotFound
}
return err
}
func (r *Reader) Taxonomy(ctx context.Context, id string) (cms.Taxonomy, error) {
var v cms.Taxonomy
err := r.db.QueryRowContext(ctx, `SELECT id,slug,name,description,revision,active FROM gwf_cms_taxonomies WHERE scope=? AND id=?`, r.scope, id).Scan(&v.ID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active)
return v, notFound(err)
}
func (r *Reader) Taxonomies(ctx context.Context) ([]cms.Taxonomy, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id,slug,name,description,revision,active FROM gwf_cms_taxonomies WHERE scope=? ORDER BY slug LIMIT 101`, r.scope)
if err != nil {
return nil, err
}
defer rows.Close()
values := []cms.Taxonomy{}
for rows.Next() {
var v cms.Taxonomy
if err := rows.Scan(&v.ID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active); err != nil {
return nil, err
}
values = append(values, v)
}
if len(values) > 100 {
return nil, cms.ErrInvalid
}
return values, rows.Err()
}
func PutTaxonomy(ctx context.Context, tx *sql.Tx, scope string, v cms.Taxonomy, expected int64) error {
if !cms.ValidID(scope) || v.Validate() != nil || expected < 0 || v.Revision != expected+1 {
return cms.ErrInvalid
}
var result sql.Result
var err error
if expected == 0 {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_taxonomies WHERE scope=?`, scope).Scan(&count); err != nil {
return err
}
if count >= 100 {
return cms.ErrInvalid
}
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_taxonomies(scope,id,slug,name,description,revision,active) VALUES(?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, v.ID, v.Slug, v.Name, v.Description, v.Revision, v.Active)
} else {
result, err = tx.ExecContext(ctx, `UPDATE gwf_cms_taxonomies SET name=?,description=?,revision=?,active=? WHERE scope=? AND id=? AND revision=? AND slug=?`, v.Name, v.Description, v.Revision, v.Active, scope, v.ID, expected, v.Slug)
}
return oneRow(result, err)
}
func oneRow(result sql.Result, err error) error {
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return cms.ErrConflict
}
return nil
}
func (r *Reader) Term(ctx context.Context, id string) (cms.Term, error) {
var v cms.Term
err := r.db.QueryRowContext(ctx, `SELECT id,taxonomy_id,slug,name,description,revision,active FROM gwf_cms_terms WHERE scope=? AND id=?`, r.scope, id).Scan(&v.ID, &v.TaxonomyID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active)
return v, notFound(err)
}
// Terms pages by immutable ID, including retired values for editors. The caller
// filters public availability using both taxonomy and term Active fields.
func (r *Reader) Terms(ctx context.Context, taxonomyID, after string, limit int) ([]cms.Term, error) {
if !cms.ValidID(taxonomyID) || (after != "" && !cms.ValidID(after)) || limit < 1 || limit > 200 {
return nil, cms.ErrInvalid
}
rows, err := r.db.QueryContext(ctx, `SELECT id,taxonomy_id,slug,name,description,revision,active FROM gwf_cms_terms WHERE scope=? AND taxonomy_id=? AND id>? ORDER BY id LIMIT ?`, r.scope, taxonomyID, after, limit)
if err != nil {
return nil, err
}
defer rows.Close()
values := []cms.Term{}
for rows.Next() {
var v cms.Term
if err := rows.Scan(&v.ID, &v.TaxonomyID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active); err != nil {
return nil, err
}
values = append(values, v)
}
return values, rows.Err()
}
// TermBySlug resolves old term slugs to the current record. The caller redirects
// to v.Slug after checking visibility; no private/retired term is published here.
func (r *Reader) TermBySlug(ctx context.Context, taxonomyID, slug string) (cms.Term, error) {
var id string
err := r.db.QueryRowContext(ctx, `SELECT id FROM gwf_cms_terms WHERE scope=? AND taxonomy_id=? AND slug=? UNION SELECT term_id FROM gwf_cms_term_slugs WHERE scope=? AND taxonomy_id=? AND slug=? LIMIT 1`, r.scope, taxonomyID, slug, r.scope, taxonomyID, slug).Scan(&id)
if err != nil {
return cms.Term{}, notFound(err)
}
return r.Term(ctx, id)
}
func PutTerm(ctx context.Context, tx *sql.Tx, scope string, v cms.Term, expected int64) error {
if !cms.ValidID(scope) || v.Validate() != nil || expected < 0 || v.Revision != expected+1 {
return cms.ErrInvalid
}
r, _ := New(tx, scope)
tax, err := r.Taxonomy(ctx, v.TaxonomyID)
if err != nil {
return err
}
if !tax.Active && v.Active {
return cms.ErrInvalid
}
var old cms.Term
if expected > 0 {
old, err = r.Term(ctx, v.ID)
if err != nil {
return err
}
if old.Revision != expected || old.TaxonomyID != v.TaxonomyID {
return cms.ErrConflict
}
}
occupied, err := r.TermBySlug(ctx, v.TaxonomyID, v.Slug)
if err == nil && occupied.ID != v.ID {
return cms.ErrConflict
}
if err != nil && !errors.Is(err, cms.ErrNotFound) {
return err
}
var result sql.Result
if expected == 0 {
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_terms(scope,id,taxonomy_id,slug,name,description,revision,active) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, v.ID, v.TaxonomyID, v.Slug, v.Name, v.Description, v.Revision, v.Active)
} else {
result, err = tx.ExecContext(ctx, `UPDATE gwf_cms_terms SET slug=?,name=?,description=?,revision=?,active=? WHERE scope=? AND id=? AND revision=?`, v.Slug, v.Name, v.Description, v.Revision, v.Active, scope, v.ID, expected)
}
if err = oneRow(result, err); err != nil {
return err
}
if expected > 0 && old.Slug != v.Slug {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_cms_term_slugs WHERE scope=? AND taxonomy_id=? AND slug=? AND term_id=?`, scope, v.TaxonomyID, v.Slug, v.ID); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_term_slugs(scope,taxonomy_id,slug,term_id) VALUES(?,?,?,?)`, scope, v.TaxonomyID, old.Slug, v.ID)
}
return err
}
+4
View File
@@ -28,6 +28,8 @@
// optional no-CGO SQLite adapter.
// - [organizations] and [access] model organizations, teams, invitations,
// scoped roles, and audited temporary access.
// - [cms] and [cmssqlite] add revision-aware taxonomies and editorial links
// alongside application-owned content and coded templates.
// - [abuse] applies application-classified request-abuse decisions.
// - [analytics] creates bounded, disposable projections from requestlog
// records without becoming a telemetry service.
@@ -58,6 +60,8 @@
// [authhttp]: https://pkg.go.dev/gamertan.com/web/authhttp
// [authsqlite]: https://pkg.go.dev/gamertan.com/web/authsqlite
// [authwebauthn]: https://pkg.go.dev/gamertan.com/web/authwebauthn
// [cms]: https://pkg.go.dev/gamertan.com/web/cms
// [cmssqlite]: https://pkg.go.dev/gamertan.com/web/cmssqlite
// [organizations]: https://pkg.go.dev/gamertan.com/web/organizations
// [requestlog]: https://pkg.go.dev/gamertan.com/web/requestlog
// [requestmeta]: https://pkg.go.dev/gamertan.com/web/requestmeta
+23
View File
@@ -21,3 +21,26 @@ template. Its private evidence, persistent bans, account data, route policy,
operator exclusions, synchronization, and publishing workflow remain
application-owned. Useful pressure from that migration may improve a general
interface, but it may not smuggle EQL-specific policy into this module.
## Optional personal-profile editing
`auth.OwnProfileRepository` supports a narrow self-service boundary independently
of instance-directory authorization. Load the profile using the current session
digest; derive the target from that result. Normalize one username or display
name using `auth.NormalizeProfileValue`. Never decode an HTTP body directly into
`auth.ProfileEdit`, which carries trusted identity and credential-check state.
Require CSRF/origin validation for browser writes and rate-limit credential work.
For username changes, verify the current password (supply its hash as
`ExpectedPasswordHash`) or consume an exact operation-bound passkey approval;
enforce any additional authentication policy your application requires. Include
the session, user, value and expected profile revision in the passkey binding.
The SQLite transaction rechecks session/account/revision and any verified hash,
updates one field, revokes other sessions for username edits, and appends audit.
Do not log the command or include secret material in its audit.
Schema 11 adds `profile_revision` without changing stable identity keys. Run an
explicit migration before starting an adopter with automatic migration disabled.
Keep the pre-migration backup; adjacent older binaries are not approved writers
for the migrated schema. Email-change enrollment/confirmation is not implemented
by this interface and must not be simulated with an unverified direct update.
+6
View File
@@ -17,6 +17,12 @@ access <──────────────────── authsqlite
An ordinary `net/http` application composes whichever branches it needs.
Optional transactional email adds `authmail` → `auth`/`mail`, with `authsqlite`
implementing its storage protocol and using `mailsqlite` for atomic encrypted
outbox writes. `mail` is standard-library-only; no broker or hosted mail service
is required. Applications own recipient authority, trusted link origins, forms,
SMTP configuration and worker scheduling. See the [protocol guide](../authmail/README.md).
Packages never own application routes, templates, authorization policy, cache
policy, or deployment. Middleware communicates through typed request context.
Storage and reporting surfaces are interfaces so an application can retain its
+74
View File
@@ -0,0 +1,74 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Classification without a page builder
`cms` defines small editorial values. `cmssqlite` stores them in caller-owned
SQLite transactions. Neither package owns your content, router, templates,
permissions or billing model. Use them alongside typed Go records and coded
templates, not as a database-defined application builder.
## Two distinct relationships
- A **taxonomy** names a flat vocabulary, such as Categories or Topics. A **term**
has a stable ID, editable name/description and an address. Renaming its address
preserves aliases; retiring it hides discovery without rewriting old revisions.
- An **explicit link** joins two `{kind, id}` references. `Related` reads that
single edge in either direction. Sharing a term alone does not assert a link.
References contain no titles, URLs, application data or authorization. Resolve
each public result through the owning repository using its current published
revision and availability. Never render the latest draft merely because an older
revision is published. Products may have additional availability/approval rules;
editorial links do not bypass them or change prices, entitlements or purchases.
## Transactions and publication
Call `CreateSchema(ctx, tx)` during an explicit application migration. It creates
the `gwf_cms_*` tables, independently of the authentication adapter's schema.
There is no automatic migration, connection, worker or request middleware.
Enable foreign keys on every connection and use the application's existing
SQLite write discipline. Keep backups and schema compatibility in that owner.
Every reader/writer takes a validated namespace. A namespace separates data; it
does not authorize the caller. Check permissions before reads and inside the
application's mutation boundary where concurrent revocation matters.
Within the same transaction as your content revision and audit:
1. `PutRevision` stores the exact revision's immutable term/link selections.
2. For publication, `SetPublished` points at that revision. Zero unpublishes.
3. Commit content, associations, publication and audit together.
Saving a draft does not advance publication. Restore by copying the selected
historical association document into a new revision, then publish separately.
Terms must exist in the namespace; retired terms remain valid historical values.
Applications decide which retired selections may be retained in new edits.
External targets can be indexed with an empty published snapshot, but their
owning module still determines whether a public link is available.
Taxonomy/term writes use expected revisions (zero for creation). Keep immutable
IDs across name changes. Taxonomy addresses are fixed after creation; term
addresses retain redirect history. A collision or stale revision returns
`cms.ErrConflict`, not a successful overwrite. Transactions must be rolled back
after any mutation error, including a later application audit failure.
## Bounds and discovery
- Each snapshot accepts at most 24 distinct terms and 16 distinct links, without
self-links. Validation rejects invalid IDs, control characters and duplicate
selections. Text fields have explicit byte bounds; names are not HTML.
- A namespace has at most 100 taxonomies. `Terms` pages by stable term ID, at
most 200 results per request. Applications should choose their own overall
editor limits and search UI rather than loading an unbounded catalog.
- `Members` and `Related` return at most 100 published references per page,
ordered by kind/ID. Pass `Next` for the following page. Publication filtering
happens before pagination; never use a mutable title as a cursor.
- Owning-module visibility can filter further. Continue fetching bounded index
pages to fill a visible page and construct a cursor from the last visible
item; do not expose private titles or identifiers through error messages.
The package tests use real SQLite transactions, including WAL, race execution,
scope isolation, delayed publication, reverse discovery, aliases, retirement,
pagination and rollback. Consumer tests still need to prove actual HTTP/API
permissions, public visibility, escaping, editor usability and application data
preservation. These packages do not claim to provide an entire CMS.
+120 -2
View File
@@ -8,9 +8,68 @@ application concern belongs in the shared module.
## Gamertan accounts and commerce
- Typed content needs shared categories and relationships without becoming a
page builder. The `cms`/`cmssqlite` boundary separates application-owned bodies
and products from immutable editorial associations. Keeping associations and
publication in the content transaction prevents a draft save from changing
public reverse links. Stable references avoid rewriting purchases on a project
rename. Consumer HTTP tests caught compiled article snapshots shadowing CMS
revisions and catalog pickers showing newer draft titles; those are application
routing/visibility responsibilities, not extra policy in this package.
- Personal identity editing is not instance administration. `OwnProfileRepository`
derives self-access from the active session; `ProfileEdit` is a trusted internal
command, never a browser request model. SQLite schema 11 adds a monotonic
revision because timestamps alone cannot distinguish two edits in one second.
Session/account/revision checks, mutation and audit share one write transaction.
Username edits invalidate other sessions without changing immutable IDs,
memberships, credentials, orders or provider billing identities. A password
proof binds the verified hash into that transaction; passkey proofs must bind
the exact user/session/field/value/revision before calling it. The application
chooses account-specific reauthentication and owns its credential-work limits.
Email requires a separate verified change protocol, not another accepted field.
- Instance operators need all-user/all-organization directories, not a staff
roster or implicit membership in every business. Optional bounded readers now
expose identity/profile records without credentials, independent of membership.
The application must authorize each call through an explicit instance scope;
these readers intentionally contain no Gamertan-specific roles or UI policy.
Stable-ID cursors and literal searches are covered against pagination gaps,
renamed profiles, inactive/personal records and wildcard/query injection.
- Customer profile and membership editing requires current ownership for every
write, not just changes involving another owner. The existing generic methods
intentionally permit application-authorized delegated administrators, so an
application preflight alone would leave a demotion race. Explicit owner-managed
methods now share their transactional cores while rechecking current direct
ownership before any write. Tests cover stale authority and optimistic state,
last-owner protection, concurrent winners, and audit-failure rollback. No extra
passkey ceremony or database migration is needed for this invariant.
- A customer may need both purchasing and billing access. Replacing one role at
a time would create partial permission states and misleading audit history.
The role-set extension commits all direct roles together with optimistic
binding IDs and current owner authority. Multiple-role invitations carry the
same combination atomically, with stored owner-managed policy rechecked when
accepted. SQLite tests cover concurrent winners, write rollback, demoted or
removed grantors, and attempted implicit reactivation of suspended members.
This adds schema 10; application vocabulary, allowed roles, invitation delivery,
ordinary-customer authentication, and UI/API commands remain application-owned.
- The public export allowlist omitted the owned-organization files introduced
in preview 22. Including them and building the exported tree tests the actual
distribution boundary rather than only comparing its path list with itself.
- Shared business purchasing exposed the difference between an initial member
and an initial RBAC owner. The historical organization creation method commits
membership but no access binding. The new `CreateOwnedOrganization` extension
grants the application-configured role and writes both audits atomically for
an existing active, fully registered user. It rejects missing roles and
unsupported adapters instead of leaving an ownerless organization behind.
SQLite tests inject failure at every write stage, including the second audit,
and race duplicate creates. Customer/merchant vocabulary remains application
policy; there is no new database schema or commerce dependency in Foundations.
- The account email remains required and unique. Gamertan uses normalized
email as the canonical login identifier and keeps username as a stable public
identity. Until a mail package exists, the application must not describe an
email as the canonical login identifier; the immutable user ID, not the editable
username, owns account relationships. Until a mail package exists, it must not describe an
address as verified merely because it was entered during registration.
- Password authentication is sufficient for an ordinary customer base
session. Privileged application actions use an exact operation binding with
@@ -34,3 +93,62 @@ application concern belongs in the shared module.
explicit operator command.
- Commerce remains a separately versioned nested module so payment-provider
policy and catalog evolution do not enlarge the authentication core.
- Self-service enrollment exposed an authorization seam: completing a valid
ceremony and checking its user only after persistence is too late.
`FinishRegistrationForUser` now consumes mismatched ceremonies and checks
the application-authenticated user before storing a credential.
- First-owner provisioning exposed another cross-package transaction boundary.
`bootstrap` now commits the passkey-only user, enrollment digest,
non-personal organization, membership, direct owner binding, and audits
together. Applications must seed their owner role first and must write the
returned raw token only to a newly created private file.
- Recovery-code consumption alone is not a complete recovery path. The
restricted grant must survive an interrupted authenticator prompt yet be
consumed in the same transaction that stores the verified replacement
passkey and replacement code digests. `authrecovery.BeginPasskey` and
`FinishPasskey` now provide that boundary without creating an authenticated
session; Gamertan keeps the raw grant only in a short-lived HttpOnly cookie.
- A portless-only WebAuthn origin rule made an unprivileged local HTTPS
exercise impossible even though WebAuthn origins include ports. The passkey
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.
- Gamertan's distinction between Site Admin and Owner exposed a second
composition boundary: permission to manage ordinary staff must not imply
permission to create, demote, suspend, or remove an Owner. Preview 19 moves
that invariant into the same SQLite transactions as direct-role and
membership changes, while leaving the application's role vocabulary and UI
policy application-owned.
- Gamertan's invitation work found the same authority boundary before a route
was exposed: Site Admin must be able to invite ordinary staff without being
able to grant or cancel Owner access. Preview 20 passes the configured owner
role into invitation mutations and rechecks a current active direct Owner
after acquiring the SQLite write lock. The application still owns fresh
authentication, recipient delivery, and the one-time secret presentation.
- A real Bitwarden/Vaultwarden owner enrollment reached successful WebAuthn
verification but was rejected by a redundant algorithm check because the
application's direct response serializer omitted the optional browser
`publicKeyAlgorithm` convenience member. Preview 21 keeps ES256-only policy
enforcement but derives it from the verified COSE key embedded in
authenticator data. This makes the server independent of serializer-specific
convenience fields without weakening origin, challenge, user-verification,
or algorithm validation.
+13 -1
View File
@@ -19,13 +19,14 @@ install an imagined framework lifecycle around it.
| SQLite persistence for `auth` | `authsqlite` | Database placement, backup, migration approval, and recovery |
| One account across organizations and teams | `organizations`, `authsqlite` | Invitation UX, organization naming, and lifecycle policy |
| Organization-scoped authorization | `access`, `authsqlite` | Role definitions, resource ownership, and route enforcement |
| First passkey-only owner and home organization | `bootstrap`, `authsqlite` | Root-local command, private token file, enrollment page, and owner-role policy |
| Aggregate projections over request records | `analytics` | Collection policy, access control, report UI, and retention |
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.10
go get gamertan.com/web/requestmeta@v0.1.0-preview.28
go mod verify
```
@@ -61,6 +62,17 @@ quietly changing identity or policy.
## Bootstrap an account without inventing a permanent password
For the first application owner, prefer `bootstrap.Start`. After explicitly
seeding the application's access policy, it creates the active passkey-only
user, non-personal home organization, membership, direct owner binding,
enrollment digest, and audit events in one repository transaction. A missing
owner role or duplicate identity rolls back every row. The application-owned
root-local command writes the returned raw enrollment token once to an
exclusive mode-`0600` file and must never print or log it.
For applications that still require a temporary password bootstrap,
`auth.GenerateTemporaryPassword` remains available:
`auth.GenerateTemporaryPassword` returns 256 bits of URL-safe cryptographic
entropy. An application can store that value in a newly created private file
and provision an account with `RequirePasswordChange: true`. The library does
+1 -1
View File
@@ -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.10
go get gamertan.com/web/requestmeta@v0.1.0-preview.25
```
Only imported packages are compiled and linked. The packages nevertheless
+154 -3
View File
@@ -8,13 +8,103 @@ environments; environments own application services. Teams are optional groups
of active organization members.
`organizations.Service` creates those resources and issues digest-backed,
expiring, single-use invitations. An invitation may carry one direct role and
up to sixteen reviewed team memberships. Acceptance verifies that the
expiring, single-use invitations. An invitation may carry up to sixteen direct
roles and sixteen reviewed team memberships. Acceptance verifies that the
authenticated user's normalized email matches and applies the membership,
role, teams, consumption marker, and audit event in one transaction.
roles, teams, consumption marker, and audit event in one transaction. The
recipient and issuing member must remain active, fully registered users of an
active organization; a suspended recipient cannot use an invitation as implicit
reactivation. Existing members use the membership editor, not another invitation,
to change roles or teams. Duplicate or concurrent acceptance consumes the token
only once. Removal revokes older pending invitations for that recipient in the
same transaction; a new, intentional invitation is needed to rejoin later.
When `OwnerRole` is configured, invitations granting that role require a current
direct owner at creation and acceptance, and an owner for revocation. Set
`OwnerManagedInvitations: true` to apply that rule to every invitation, including
ordinary member invitations. Stored `RequiredOwnerRole` preserves the boundary
even when a link reaches another application service with different options.
A broad access-management permission can still administer ordinary invitations
when owner-managed policy is disabled, but cannot create or cancel owner access.
Applications own invitation pages, email or out-of-band delivery, active-source
checks before archival, and account recovery.
Use `InviteWithAccess.DirectRoles` for combinations and `RequestID` for the
creation audit correlation. `DirectRole` remains the legacy single-role form;
supplying both is rejected, not merged. The service copies and sorts role arrays
and rejects duplicates or unknown/unseeded roles before persistence. Repository
adapters implement `RoleInvitationRepository` to store and enforce role-set and
owner requirements atomically. An unsupported adapter returns
`ErrRoleInvitationUnsupported`; it must not issue a partly effective invitation.
The application restricts which roles may be offered and authenticates the actor;
never accept the owner-role policy or actor identity from submitted fields.
## Creating an organization with an owner
For instance-wide administrative directories, the optional
`auth.UserDirectoryRepository` and `organizations.DirectoryRepository` readers
on `authsqlite.Store` list all identities/organizations, not just memberships.
**Authorize an explicit instance-read capability before every call.** These are
not customer self-service or public directory APIs; they deliberately include
incomplete/inactive accounts and personal/archived organizations without exposing
credentials, recovery material or invitations. Merchant classification remains
application policy. Reading never creates a membership or grants a role.
Both queries accept literal `Search` (up to 128 bytes), exclusive `AfterID`, and
`Limit` (default 50, maximum 200). An empty `NextID` ends the result. Preserve the
search when following a cursor; reset it when changing the search. IDs give stable
ordering despite renamed profiles, but pages are current views rather than a
multi-request snapshot. New records sorting before a cursor appear on a fresh
listing. SQLite search folds ASCII case; non-ASCII display-name text matches with
its original case. Wildcards and SQL fragments are always literal search text.
These optional readers do not change the required authentication/organization
repository contracts or schema 10.
For an existing authenticated user creating a business, use
`CreateOwnedOrganization` with `OwnerRole` configured when constructing the
service. Seed that role first. This commits the organization, active membership,
direct organization-wide owner binding, and both creation/access audit events in
one transaction. `CreateOrganization.RequestID` correlates those audit events.
The owner must be an active user whose registration has completed.
The application authorizes creation and chooses the role; do not accept an owner
role name from a browser or API payload. A customer-owner role can intentionally
have different permissions from an installation's merchant-owner role. Creating
a customer organization grants no authority in any other organization.
```go
customers, err := organizations.New(store, organizations.Options{
OwnerRole: "customer.owner", // Application-defined, already seeded.
OwnerManagedInvitations: true,
})
if err != nil {
return err
}
business, err := customers.CreateOwnedOrganization(ctx, organizations.CreateOrganization{
Slug: "example-business", Name: "Example Business", OwnerUserID: principal.User.ID,
RequestID: requestID,
})
```
Repositories implement `OwnedOrganizationRepository` to support this operation.
There is no create-then-grant fallback: unsupported adapters return
`ErrOwnedCreationUnsupported`. The older `CreateOrganization` and
`CreatePersonalOrganization` retain their membership-only behavior; configuring
`OwnerRole` does not silently change them. The separate `account` package still
owns atomic public signup, including personal organization and credentials.
## Membership and access lifecycle
For customer-owned businesses where only owners manage profiles and members,
use `UpdateOwnedOrganization`, `ChangeOwnedMembershipStatus`, and
`RemoveOwnedMembershipIfCurrent`. They recheck the service's configured direct
owner after acquiring the write lock, including when the target is a non-owner.
The actor must remain active and fully registered, their membership must remain
active, and the organization must be active and non-personal. Profile changes
only update name and slug; archiving and personal-account lifecycle are separate.
Custom adapters must implement `OwnerManagedRepository`, with no fallback to an
application preflight followed by an unguarded write. These additions retain
schema 10 and do not change the existing delegated-administration methods below.
Organizations and teams use optimistic revisions and reversible
`active`/`archived` states. Archived objects keep their history but contribute
no effective authority. Memberships may be suspended, reactivated, or removed;
@@ -22,6 +112,58 @@ 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.
For combinations such as Buyer plus Billing Manager, use
`access.ReplaceOrganizationUserRoles` with a non-empty, unique `Roles` array
(maximum sixteen) and the same `ExpectedBindingIDs` convention. This operation
requires a current direct owner inside the write transaction for every change;
the older single-role API retains its delegated non-owner administration policy.
The replacement is all-or-nothing, leaves narrower grants untouched, and records
one audit. `ErrRoleChangeConflict` means refresh the displayed bindings, not retry
the old request silently. `RoleSetRepository` is required; separate grant/revoke
calls are not a fallback. A basic-member role with no permissions can represent
membership without purchasing or billing access.
Role names and capabilities remain application policy. In particular, customer
roles must not be replaceable with merchant roles merely because both policies
use the same database. Routine customer changes do not inherently require a
passkey ceremony; the application decides when an action needs fresh proof.
## Schema 10 compatibility
Schema 10 adds `direct_roles_json` and `required_owner_role` to stored invitations.
The explicit migration preserves legacy `direct_role`, hashes, dates, teams, and
consumption state. It does not guess which application role historically meant
owner. Configure the correct `OwnerRole` when accepting pre-schema-10 owner
invitations; that service policy supplies their acceptance-time owner check.
New invitations carry the persisted requirement themselves.
Use `OpenWithOptions(..., OpenOptions{Migrate: false})` plus
`RequireCurrentSchema` at application startup and an explicit operator migration
command. Retain a verified backup before migrating. Schema-9 binaries reject
schema 10 when using the startup check and are not approved writers after the
upgrade; a binary rollback must not overwrite newer accepted data.
`access.Service` evaluates a permission against a complete resource scope:
```go
@@ -43,6 +185,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
+57 -10
View File
@@ -9,11 +9,20 @@ authorization decisions, session cookie, HTML, and local recovery command.
## Fixed security policy
- Use an exact HTTPS origin whose hostname equals the relying-party ID.
- Keep production origins portless. For local development only,
`AllowDevelopmentPort` permits one explicit non-default port when the RP ID
is exactly `localhost` or beneath the reserved `.test` top-level domain. The
configured origin, browser `Origin`, and WebAuthn verifier origin must still
match exactly.
- Reject cross-origin ceremonies.
- Require discoverable credentials and user verification.
- Request no attestation conveyance.
- Permit ES256 only until another algorithm has explicit interoperability and
security evidence.
- Enforce that policy from the verified COSE public key embedded in
authenticator data. Do not rely on the optional browser
`publicKeyAlgorithm` convenience member: direct standards-compliant response
serializers may omit it even when the attested credential is ES256.
- Store random challenges and verifier session data only behind opaque,
single-use ceremony tokens.
- Treat clone warnings as audit signals rather than automatic lockout for
@@ -26,12 +35,17 @@ timestamp, UUID, or counter for the random challenge.
## Application flow
1. A local command calls `Bootstrap` or `Recover` and writes the returned
enrollment token once to a newly created mode-`0600` file.
1. A local command calls `authwebauthn.Bootstrap`, `authwebauthn.Recover`, or
`bootstrap.Start` and writes the returned enrollment token once to a newly
created mode-`0600` file. Use `bootstrap.Start` for the first application
owner so identity, organization membership, direct owner access, and audits
cannot be partially committed.
2. A server-rendered enrollment page calls `BeginEnrollment`; the browser uses
`navigator.credentials.create` with the returned `public_key` value.
3. The browser posts the credential and opaque ceremony token to a bounded JSON
endpoint; `FinishRegistration` verifies and stores the public credential.
endpoint; authenticated self-service flows use
`FinishRegistrationForUser` so the application session's user ID is checked
before any public credential is stored.
4. Login uses `BeginLogin`, `navigator.credentials.get`, and `FinishLogin`.
The successful result contains an ordinary opaque `auth` session token.
5. Sensitive operations call `BeginApproval` with a canonical application
@@ -51,13 +65,46 @@ JavaScript, or set sessions automatically.
## Recovery and credential lifecycle
Recovery is deliberately host-local and should never be reachable through an
HTTP handler. It revokes all user sessions and pending ceremonies, replaces
prior enrollment tokens, appends a secret-free audit event, and returns one
15-minute token. It does not delete existing passkeys. After enrolling a
replacement, the operator reviews credential labels and removes lost keys with
a fresh passkey-bound removal ceremony. The final passkey cannot be removed
remotely.
Administrator-assisted `authwebauthn.Recover` is deliberately host-local and
must never be reachable through an HTTP handler. It revokes all user sessions
and pending ceremonies, replaces prior enrollment tokens, appends a
secret-free audit event, and returns one 15-minute token. It does not delete
existing passkeys. After enrolling a replacement, the operator reviews
credential labels and removes lost keys with a fresh passkey-bound removal
ceremony. The final passkey cannot be removed remotely.
An account may separately expose self-service password-plus-recovery-code
recovery through `authrecovery`. `Begin` verifies the password, consumes one
printable code, revokes sessions, and returns a short-lived grant—not a normal
session. Keep that grant in a narrowly scoped, Secure, HttpOnly, SameSite cookie
and never place it in a URL. `BeginPasskey` binds its digest into the WebAuthn
ceremony. `FinishPasskey` atomically consumes the grant, stores the verified
replacement passkey, replaces the entire recovery-code set, revokes any
sessions or ceremonies created during recovery, and returns the new plaintext
codes exactly once. It does not issue a session; return the user to normal
login after displaying and saving the new codes.
A failed storage commit leaves the restricted grant available for a fresh
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.
+5
View File
@@ -9,3 +9,8 @@ the exact same exported tree as a read-only discovery mirror.
The exporter includes no branches, reflogs, private operational evidence,
credentials, databases, logs, or development-only files. Public Gitea issues
and pull requests are the contribution venue.
`scripts/test-public-snapshot.sh` checks the exact allowlist and builds all
exported packages. New implementation and regression-test files must be included
explicitly; a successful build in the development checkout does not prove that
the smaller exported distribution is complete.
+17
View File
@@ -59,6 +59,23 @@ configured reverse proxy, authorize application routes automatically, encrypt a
compromised host, or decide how long an operator may lawfully retain personal
request evidence.
Optional account mail treats email links as bearer secrets, not sessions or MFA.
`authmail` binds random single-use digests to account identity, purpose, current
credential and expiry. Address changes additionally require current password,
session and both mailboxes. Reset requires an already verified current mailbox;
success creates no login and removes no enrolled factor. The SQLite adapter
atomically rechecks authority, revokes sessions/grants and queues notices with
the audit. Existing ownership and financial records are not reassigned.
SMTP authenticates only after verified TLS with no plaintext fallback. Encrypted
outbox payloads require an application-owned key, which may itself be wrapped in
SQLite only when its external wrapping key is kept separately. Compromise of
the sender, recipient mailbox or application host remains a threat. SMTP DATA
acceptance is not inbox delivery and crash retries are not exactly once. Forms,
CSRF/origin enforcement, fragment-to-POST handling, IP/concurrency limits and
non-enumerating responses remain explicit consumer responsibilities; see the
[integration boundaries](../authmail/README.md).
Applications must pass the authenticated user and requested resource hierarchy
to `access.Authorize`; possessing a platform-level `auth` role does not bypass
that decision. Team membership is resolved by the repository rather than
+32
View File
@@ -0,0 +1,32 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Transactional mail transport
`mail.Message` is one bounded UTF-8 plain-text message with a stable ID/date and
one ASCII envelope recipient. `NewSMTP` binds a fixed, configured sender and
dedicated SMTP credentials. Use implicit TLS (normally465) or required STARTTLS
(normally587). Certificate chain and hostname verification are mandatory; no
plaintext fallback or caller-provided arbitrary headers/attachments exist.
The standard-library transport uses a bounded connection deadline and cancellation
across TLS and SMTP. Message IDs survive retries. Errors expose only a fixed
stage, numeric SMTP code and retry classification; raw server responses are not
propagated because they can contain addresses or credentials. Successful DATA
acceptance is success even if QUIT fails. A connection failure around acceptance
can still produce duplicate delivery on retry: SMTP is not exactly-once transport,
and acceptance is not proof of inbox placement.
Applications own authorized recipients, email templates, trusted HTTPS link
origins, rate limits, encrypted persistence and the worker. Never log message
content or serialize credentials into ordinary diagnostics. Debug string methods
redact sensitive content but are not a substitute for safe logging policy.
No account verification/reset protocol is implemented by this transport itself.
Verified locally with disposable SMTP servers: implicit/STARTTLS delivery,
certificate/hostname rejection, no-downgrade behavior, auth/recipient error
classification, cancellation after connection, MIME round-trip, injection/bounds
and acceptance followed by QUIT failure. `go test -race ./mail` and `go vet ./mail`
pass on Go1.26.6. No real credentials, message delivery, public package release or
consumer deployment are claimed. The [encrypted outbox](../mailsqlite/README.md)
adds transactional persistence; [authmail](../authmail/README.md) supplies optional
account protocols with consumer-owned forms and authorization boundaries.
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: MPL-2.0
// Package mail provides bounded, single-recipient transactional messages.
// Applications own authorization, templates, trusted link origins and consent.
package mail
import (
"bytes"
"context"
"errors"
"fmt"
"mime"
"mime/quotedprintable"
stdmail "net/mail"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
)
const MaxTextBytes = 64 << 10
var ErrInvalid = errors.New("mail: invalid message or configuration")
var messageIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{15,63}$`)
// Message has no caller-controlled From, arbitrary headers or recipient list.
// ID and CreatedAt stay stable across delivery retries. Treat Text as sensitive:
// never log it, and encrypt it when persisted (it may contain a recovery link).
type Message struct {
ID, To, Subject, Text string
CreatedAt time.Time
}
// String and GoString avoid accidental plaintext recovery-link logging during
// debugging. Structured serializers still require the same care as any secret.
func (Message) String() string { return "mail.Message{content:redacted}" }
func (message Message) GoString() string { return message.String() }
type Transport interface {
// Send returns nil after the SMTP server accepts DATA, not after inbox delivery.
// Implementations must honor context cancellation and deadlines. The outbox
// relies on this bound to finish transmission before its worker lease expires.
Send(context.Context, Message) error
}
func (message Message) Validate() error {
if !messageIDPattern.MatchString(message.ID) || !validMailbox(message.To) ||
!headerText(message.Subject, 256) || message.CreatedAt.Year() < 1970 || message.CreatedAt.Year() > 9999 ||
len(message.Text) == 0 || len(message.Text) > MaxTextBytes || !utf8.ValidString(message.Text) {
return ErrInvalid
}
for _, r := range message.Text {
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
return ErrInvalid
}
}
return nil
}
func headerText(value string, maximum int) bool {
if strings.TrimSpace(value) == "" || len(value) > maximum || !utf8.ValidString(value) {
return false
}
for _, r := range value {
if unicode.IsControl(r) {
return false
}
}
return true
}
// This transport uses ASCII envelope addresses, not SMTPUTF8. Display names and
// subjects may be UTF-8; MIME encodes them. The application owns normalization.
func validMailbox(value string) bool {
if len(value) > 254 || !headerText(value, 254) {
return false
}
for _, r := range value {
if r > 127 {
return false
}
}
parsed, err := stdmail.ParseAddress(value)
return err == nil && parsed.Name == "" && parsed.Address == value && strings.Contains(value, "@")
}
func encode(message Message, from *stdmail.Address) ([]byte, error) {
if err := message.Validate(); err != nil {
return nil, err
}
var buffer bytes.Buffer
domain := from.Address[strings.LastIndexByte(from.Address, '@')+1:]
fmt.Fprintf(&buffer, "From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%s@%s>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", from.String(), (&stdmail.Address{Address: message.To}).String(), mime.QEncoding.Encode("UTF-8", message.Subject), message.CreatedAt.UTC().Format(time.RFC1123Z), message.ID, domain)
body := strings.ReplaceAll(strings.ReplaceAll(message.Text, "\r\n", "\n"), "\r", "\n")
writer := quotedprintable.NewWriter(&buffer)
if _, err := writer.Write([]byte(body)); err != nil {
return nil, err
}
if err := writer.Close(); err != nil {
return nil, err
}
if !bytes.HasSuffix(buffer.Bytes(), []byte("\r\n")) {
buffer.WriteString("\r\n")
}
return buffer.Bytes(), nil
}
+354
View File
@@ -0,0 +1,354 @@
// SPDX-License-Identifier: MPL-2.0
package mail
import (
"bufio"
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"errors"
"fmt"
"io"
"math/big"
"mime"
"mime/quotedprintable"
"net"
stdmail "net/mail"
"net/textproto"
"strconv"
"strings"
"testing"
"time"
)
func sampleMessage() Message {
return Message{ID: "mail_fixture_1234567890", To: "reader@example.test", Subject: "Your account — a little care ♥", Text: "Hello!\nConfirm your email: https://example.test/verify/?token=disposable\n.One line with a dot.\n", CreatedAt: time.Date(2026, 9, 11, 5, 0, 0, 0, time.UTC)}
}
func TestMessageEncodingAndBounds(t *testing.T) {
from := &stdmail.Address{Name: "Cole ♥", Address: "support@example.test"}
message := sampleMessage()
if strings.Contains(fmt.Sprintf("%+v %#v", message, message), "disposable") {
t.Fatal("message debug output exposed a token")
}
encoded, err := encode(message, from)
if err != nil {
t.Fatal(err)
}
parsed, err := stdmail.ReadMessage(bytes.NewReader(encoded))
if err != nil {
t.Fatal(err)
}
subject, err := (&mime.WordDecoder{}).DecodeHeader(parsed.Header.Get("Subject"))
if err != nil || subject != message.Subject {
t.Fatal("subject did not round-trip")
}
if parsed.Header.Get("Message-ID") != "<"+message.ID+"@example.test>" {
t.Fatal("unstable message ID")
}
if _, err = parsed.Header.Date(); err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(quotedprintable.NewReader(parsed.Body))
if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != message.Text {
t.Fatal("body did not round-trip")
}
for _, line := range strings.Split(string(encoded), "\r\n") {
if len(line) > 998 || strings.ContainsRune(line, '\n') {
t.Fatal("invalid MIME line")
}
}
for _, mutate := range []func(*Message){
func(m *Message) { m.ID = "bad\r\nID" }, func(m *Message) { m.To = "a@example.test,b@example.test" }, func(m *Message) { m.To = "Reader <reader@example.test>" },
func(m *Message) { m.To = "\"line\r\nbreak\"@example.test" }, func(m *Message) { m.To = "élève@example.test" }, func(m *Message) { m.Subject = "Subject\r\nBcc: other@example.test" },
func(m *Message) { m.Subject = strings.Repeat("a", 257) }, func(m *Message) { m.Text = strings.Repeat("x", MaxTextBytes+1) }, func(m *Message) { m.Text = "bad\x00body" },
func(m *Message) { m.Text = string([]byte{0xff}) }, func(m *Message) { m.CreatedAt = time.Time{} },
} {
bad := sampleMessage()
mutate(&bad)
if _, err := encode(bad, from); !errors.Is(err, ErrInvalid) {
t.Fatal("invalid message accepted")
}
}
}
type smtpFixtureOptions struct {
mode TLSMode
noSTARTTLS, rejectAuth, rejectRecipient, temporary, closeAfterAccept, stall bool
connected chan struct{}
}
type smtpCapture struct {
commands []string
authenticatedTLS bool
sender, recipient, body string
accepted bool
}
func smtpFixture(t *testing.T, options smtpFixtureOptions) (SMTPConfig, <-chan smtpCapture) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
now := time.Now()
template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, DNSNames: []string{"localhost"}, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certificate, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AddCert(certificate)
tlsConfig := &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, MinVersion: tls.VersionTLS12}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { listener.Close() })
_, portText, _ := net.SplitHostPort(listener.Addr().String())
port, _ := strconv.Atoi(portText)
result := make(chan smtpCapture, 1)
go func() {
capture := smtpCapture{}
defer func() { result <- capture }()
connection, err := listener.Accept()
if err != nil {
return
}
defer connection.Close()
if options.connected != nil {
close(options.connected)
}
_ = connection.SetDeadline(time.Now().Add(5 * time.Second))
secure := options.mode == ImplicitTLS
if secure {
wire := tls.Server(connection, tlsConfig)
if wire.Handshake() != nil {
return
}
connection = wire
}
protocol := textproto.NewConn(connection)
if options.stall {
_, _ = protocol.ReadLine()
return
}
if protocol.PrintfLine("220 localhost test mail") != nil {
return
}
for {
line, err := protocol.ReadLine()
if err != nil {
return
}
verb, _, _ := strings.Cut(line, " ")
capture.commands = append(capture.commands, verb)
switch verb {
case "EHLO":
if !secure && !options.noSTARTTLS {
err = protocol.PrintfLine("250-localhost\r\n250 STARTTLS")
} else {
err = protocol.PrintfLine("250-localhost\r\n250 AUTH PLAIN")
}
case "STARTTLS":
if protocol.PrintfLine("220 Ready for TLS") != nil {
return
}
wire := tls.Server(connection, tlsConfig)
if wire.Handshake() != nil {
return
}
connection = wire
protocol = textproto.NewConn(connection)
secure = true
case "AUTH":
capture.authenticatedTLS = secure
if line != "AUTH PLAIN "+base64.StdEncoding.EncodeToString([]byte("\x00app-sender\x00fixture-password")) || options.rejectAuth {
err = protocol.PrintfLine("535 credential-detail-must-not-leak")
} else {
err = protocol.PrintfLine("235 Authenticated")
}
case "MAIL":
capture.sender = strings.TrimPrefix(line, "MAIL FROM:")
err = protocol.PrintfLine("250 Sender accepted")
case "RCPT":
capture.recipient = strings.TrimPrefix(line, "RCPT TO:")
if options.rejectRecipient {
code := 550
if options.temporary {
code = 450
}
err = protocol.PrintfLine("%d recipient-detail-must-not-leak", code)
} else {
err = protocol.PrintfLine("250 Recipient accepted")
}
case "DATA":
if protocol.PrintfLine("354 Send message") != nil {
return
}
body, readErr := io.ReadAll(io.LimitReader(protocol.DotReader(), MaxTextBytes*2))
if readErr != nil {
return
}
capture.body = string(body)
capture.accepted = true
err = protocol.PrintfLine("250 Accepted")
if options.closeAfterAccept {
return
}
case "QUIT":
_ = protocol.PrintfLine("221 Goodbye")
return
default:
err = protocol.PrintfLine("500 Unexpected command")
}
if err != nil {
return
}
}
}()
return SMTPConfig{Host: "localhost", Port: port, TLSMode: options.mode, Username: "app-sender", Password: "fixture-password", From: "Cole <support@example.test>", Timeout: 3 * time.Second, RootCAs: roots}, result
}
func takeCapture(t *testing.T, result <-chan smtpCapture) smtpCapture {
t.Helper()
select {
case capture := <-result:
return capture
case <-time.After(6 * time.Second):
t.Fatal("SMTP fixture did not finish")
return smtpCapture{}
}
}
func TestSMTPSecureDelivery(t *testing.T) {
for _, mode := range []TLSMode{ImplicitTLS, RequiredSTARTTLS} {
for _, closeAfterAccept := range []bool{false, true} {
t.Run(string(mode)+"/quit="+strconv.FormatBool(closeAfterAccept), func(t *testing.T) {
config, result := smtpFixture(t, smtpFixtureOptions{mode: mode, closeAfterAccept: closeAfterAccept})
transport, err := NewSMTP(config)
if err != nil {
t.Fatal(err)
}
if err = transport.Send(t.Context(), sampleMessage()); err != nil {
t.Fatal(err)
}
capture := takeCapture(t, result)
if !capture.authenticatedTLS || !capture.accepted || capture.sender != "<support@example.test>" || capture.recipient != "<reader@example.test>" {
t.Fatal("SMTP identity or TLS boundary failed")
}
message, err := stdmail.ReadMessage(bufio.NewReader(strings.NewReader(capture.body)))
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(quotedprintable.NewReader(message.Body))
if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != sampleMessage().Text {
t.Fatal("SMTP body changed")
}
})
}
}
}
func TestSMTPRejectsDowngradeCertificatesAndProviderErrors(t *testing.T) {
for _, tc := range []struct {
name string
options smtpFixtureOptions
untrusted, wrongHost bool
stage string
code int
retry bool
}{
{name: "no STARTTLS", options: smtpFixtureOptions{mode: RequiredSTARTTLS, noSTARTTLS: true}, stage: "starttls-required"},
{name: "untrusted implicit TLS", options: smtpFixtureOptions{mode: ImplicitTLS}, untrusted: true, stage: "tls"},
{name: "untrusted STARTTLS", options: smtpFixtureOptions{mode: RequiredSTARTTLS}, untrusted: true, stage: "tls"},
{name: "wrong hostname", options: smtpFixtureOptions{mode: ImplicitTLS}, wrongHost: true, stage: "tls"},
{name: "bad credential", options: smtpFixtureOptions{mode: ImplicitTLS, rejectAuth: true}, stage: "authentication", code: 535},
{name: "bad recipient", options: smtpFixtureOptions{mode: ImplicitTLS, rejectRecipient: true}, stage: "recipient", code: 550},
{name: "temporary recipient", options: smtpFixtureOptions{mode: ImplicitTLS, rejectRecipient: true, temporary: true}, stage: "recipient", code: 450, retry: true},
} {
t.Run(tc.name, func(t *testing.T) {
config, result := smtpFixture(t, tc.options)
if tc.untrusted {
config.RootCAs = x509.NewCertPool()
}
if tc.wrongHost {
config.Host = "127.0.0.1"
}
transport, err := NewSMTP(config)
if err != nil {
t.Fatal(err)
}
err = transport.Send(t.Context(), sampleMessage())
var failure *Error
if !errors.As(err, &failure) || failure.Stage != tc.stage || failure.Code != tc.code || failure.Retryable != tc.retry {
t.Fatalf("unexpected safe failure: %v", err)
}
if strings.Contains(err.Error(), "must-not-leak") || strings.Contains(err.Error(), "fixture-password") || strings.Contains(err.Error(), "reader@example.test") {
t.Fatal("SMTP response leaked")
}
capture := takeCapture(t, result)
if capture.accepted {
t.Fatal("rejected delivery accepted DATA")
}
if tc.code == 0 && capture.authenticatedTLS {
t.Fatal("authenticated despite failed secure negotiation")
}
})
}
}
func TestSMTPCancellationAndInvalidInput(t *testing.T) {
connected := make(chan struct{})
config, result := smtpFixture(t, smtpFixtureOptions{mode: RequiredSTARTTLS, stall: true, connected: connected})
if strings.Contains(fmt.Sprintf("%+v %#v", config, config), "fixture-password") {
t.Fatal("configuration debug output exposed a credential")
}
transport, err := NewSMTP(config)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
completed := make(chan error, 1)
go func() { completed <- transport.Send(ctx, sampleMessage()) }()
select {
case <-connected:
case <-time.After(5 * time.Second):
t.Fatal("fixture connection missing")
}
cancel()
select {
case err = <-completed:
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation error=%v", err)
}
case <-time.After(time.Second):
t.Fatal("SMTP cancellation did not stop promptly")
}
if capture := takeCapture(t, result); capture.accepted {
t.Fatal("cancelled send accepted")
}
for _, mutate := range []func(*SMTPConfig){func(c *SMTPConfig) { c.TLSMode = "plaintext" }, func(c *SMTPConfig) { c.Host = "bad\nserver" }, func(c *SMTPConfig) { c.From = "a@example.test,b@example.test" }, func(c *SMTPConfig) { c.Password = "" }, func(c *SMTPConfig) { c.Port = 0 }, func(c *SMTPConfig) { c.Timeout = time.Hour }} {
bad := config
mutate(&bad)
if _, err := NewSMTP(bad); !errors.Is(err, ErrInvalid) {
t.Fatal("invalid SMTP config accepted")
}
}
bad := sampleMessage()
bad.To = "invalid"
if err := transport.Send(t.Context(), bad); !errors.Is(err, ErrInvalid) {
t.Fatal("invalid message reached transport")
}
}
+173
View File
@@ -0,0 +1,173 @@
// SPDX-License-Identifier: MPL-2.0
package mail
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
stdmail "net/mail"
"net/smtp"
"net/textproto"
"strconv"
"strings"
"time"
)
type TLSMode string
const (
ImplicitTLS TLSMode = "implicit"
RequiredSTARTTLS TLSMode = "starttls"
)
// SMTPConfig is trusted server configuration, never an HTTP payload. Keep its
// credentials in private operator-managed storage and do not log this value.
type SMTPConfig struct {
Host string
Port int
TLSMode TLSMode
Username, Password, From string
Timeout time.Duration
// RootCAs supplies an explicit trust pool (for example private PKI).
// Nil uses system roots. Hostname verification cannot be disabled.
RootCAs *x509.CertPool
}
func (SMTPConfig) String() string { return "mail.SMTPConfig{credentials:redacted}" }
func (config SMTPConfig) GoString() string { return config.String() }
type SMTP struct {
address, host, username, password string
from *stdmail.Address
tlsMode TLSMode
timeout time.Duration
rootCAs *x509.CertPool
}
func (*SMTP) String() string { return "mail.SMTP{credentials:redacted}" }
func (transport *SMTP) GoString() string { return transport.String() }
// Error contains only a fixed stage and numeric SMTP code, never the server's
// response (which can echo a mailbox, authentication token or message content).
type Error struct {
Stage string
Code int
Retryable bool
}
func (err *Error) Error() string {
return fmt.Sprintf("mail: SMTP %s failed (code %d)", err.Stage, err.Code)
}
func NewSMTP(config SMTPConfig) (*SMTP, error) {
if config.Timeout == 0 {
config.Timeout = 15 * time.Second
}
if !headerText(config.Host, 253) || strings.ContainsAny(config.Host, " \t\r\n/@[]\\") || config.Port < 1 || config.Port > 65535 ||
(config.TLSMode != ImplicitTLS && config.TLSMode != RequiredSTARTTLS) || config.Timeout < time.Second || config.Timeout > 30*time.Second ||
!headerText(config.Username, 254) || config.Password == "" || len(config.Password) > 4096 || strings.ContainsAny(config.Password, "\x00\r\n") || !headerText(config.From, 320) {
return nil, ErrInvalid
}
from, err := stdmail.ParseAddress(config.From)
if err != nil || !validMailbox(from.Address) || from.Name != "" && !headerText(from.Name, 128) {
return nil, ErrInvalid
}
var roots *x509.CertPool
if config.RootCAs != nil {
roots = config.RootCAs.Clone()
}
return &SMTP{address: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), host: config.Host, username: config.Username, password: config.Password, from: from, tlsMode: config.TLSMode, timeout: config.Timeout, rootCAs: roots}, nil
}
func failure(ctx context.Context, stage string, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
result := &Error{Stage: stage, Retryable: true}
var protocol *textproto.Error
if errors.As(err, &protocol) {
result.Code = protocol.Code
result.Retryable = protocol.Code >= 400 && protocol.Code < 500
}
var unknownAuthority x509.UnknownAuthorityError
var hostname x509.HostnameError
var invalidCertificate x509.CertificateInvalidError
if errors.As(err, &unknownAuthority) || errors.As(err, &hostname) || errors.As(err, &invalidCertificate) {
result.Retryable = false
}
return result
}
// Send never falls back to plaintext, even on localhost. Cancellation closes the
// connection and every protocol step shares one bounded deadline. SMTP can accept
// DATA just before a connection failure; retrying that uncertainty may duplicate
// mail. A failure of QUIT after DATA acceptance does not trigger another send.
func (transport *SMTP) Send(ctx context.Context, message Message) error {
if transport == nil {
return ErrInvalid
}
payload, err := encode(message, transport.from)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, transport.timeout)
defer cancel()
connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", transport.address)
if err != nil {
return failure(ctx, "connect", err)
}
defer connection.Close()
stop := context.AfterFunc(ctx, func() { _ = connection.Close() })
defer stop()
deadline, _ := ctx.Deadline()
if err = connection.SetDeadline(deadline); err != nil {
return failure(ctx, "connect", err)
}
tlsConfig := &tls.Config{ServerName: transport.host, MinVersion: tls.VersionTLS12, RootCAs: transport.rootCAs}
var wire net.Conn = connection
if transport.tlsMode == ImplicitTLS {
secure := tls.Client(connection, tlsConfig)
if err = secure.HandshakeContext(ctx); err != nil {
return failure(ctx, "tls", err)
}
wire = secure
}
client, err := smtp.NewClient(wire, transport.host)
if err != nil {
return failure(ctx, "greeting", err)
}
defer client.Close()
if transport.tlsMode == RequiredSTARTTLS {
if ok, _ := client.Extension("STARTTLS"); !ok {
return &Error{Stage: "starttls-required"}
}
if err = client.StartTLS(tlsConfig); err != nil {
return failure(ctx, "tls", err)
}
}
if err = client.Auth(smtp.PlainAuth("", transport.username, transport.password, transport.host)); err != nil {
return failure(ctx, "authentication", err)
}
if err = client.Mail(transport.from.Address); err != nil {
return failure(ctx, "sender", err)
}
if err = client.Rcpt(message.To); err != nil {
return failure(ctx, "recipient", err)
}
data, err := client.Data()
if err != nil {
return failure(ctx, "data", err)
}
if _, err = data.Write(payload); err != nil {
return failure(ctx, "data", err)
}
if err = data.Close(); err != nil {
return failure(ctx, "acceptance", err)
}
_ = client.Quit()
return nil
}
+47
View File
@@ -0,0 +1,47 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Encrypted transactional outbox
`mailsqlite.Queue` stores a single-recipient `mail.Message` as authenticated
AES-256-GCM ciphertext. Use a separate 32-byte application-managed secret, kept
out of source control and logs; back it up separately. It may be wrapped in SQLite
only if the wrapping key stays outside the database and is backed up separately.
Identity and expiry
are bound to the ciphertext. Key-derived HMACs support idempotency without storing
plaintext message hashes. Losing the key loses pending message contents.
Call `CreateSchema` inside the application's explicit, versioned migration.
`EnqueueTx` joins a caller-owned transaction, allowing account changes, audit and
mail intent to commit or roll back together. `Enqueue` is a convenience for a
standalone transaction. A message ID belongs to exactly one message/expiry, even
after its payload is cleared. Do not use the queue to authorize recipients.
`ProcessOne` commits a one-minute claim before calling the transport; it never
holds a database writer lock over SMTP. Transports must honor the supplied
deadline (at most 30 seconds). A stale worker cannot acknowledge a newer lease.
Retryable failures back off for 1, 2, 4 and 8 minutes, up to five attempts, only
while the message is valid. Application workers own scheduling and shutdown.
An incorrect key or corrupt payload never reaches SMTP. Such work retains its
ciphertext and retries decoding after five minutes without consuming a delivery
attempt. Restoring the correct key before expiry can recover pending messages.
This is not transparent key rotation: drain the old queue or provide an explicit
migration before changing keys.
Payloads expire within 24 hours and are cleared after terminal delivery results
or expiry. Run `Sweep` periodically even when sending is disabled; each call is
bounded to 100 records. Pending capacity defaults to 1,000 (maximum 10,000).
Safe metadata/deduplication tombstones remain; applications own any later bounded
retention policy and must not reuse purged IDs. Never expose `Recent` publicly.
SMTP acceptance is not inbox delivery. A crash or lost acknowledgement can cause
a retry after the remote server accepted DATA. Stable Message-ID helps diagnose
duplicates but cannot make SMTP exactly-once. Do not use this queue for payments
or another external operation requiring an exactly-once commitment.
Local Go/race/vet tests cover encryption and identity binding, domain rollback,
idempotency, concurrent capacity/claims, lock-free network waits, stale workers,
cancelled acknowledgements, retry bounds, safe diagnostics, expiry and wrong-key
recovery. No real SMTP credential, provider delivery or consumer deployment is
claimed here. [Account verification/reset protocols](../authmail/README.md) are a
separate optional layer, not behavior inferred by the queue.
+334
View File
@@ -0,0 +1,334 @@
// SPDX-License-Identifier: MPL-2.0
// Package mailsqlite provides an encrypted transactional mail outbox. Callers
// own schema-version journals, authorization, rate limits and connection opening.
package mailsqlite
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"time"
"gamertan.com/web/mail"
)
var (
ErrInvalid = errors.New("mailsqlite: invalid outbox operation")
ErrConflict = errors.New("mailsqlite: message identity or lease conflict")
ErrFull = errors.New("mailsqlite: pending outbox limit reached")
)
const maxAttempts = 5
const leaseDuration = time.Minute
type Options struct {
// EncryptionKey is a separate 32-byte application-managed secret. Back it up
// separately from SQLite. Losing/changing it makes pending messages unreadable.
EncryptionKey []byte
MaxPending int
Now func() time.Time
}
type Queue struct {
db *sql.DB
aead cipher.AEAD
digestKey []byte
maxPending int
now func() time.Time
}
func (*Queue) String() string { return "mailsqlite.Queue{keys:redacted}" }
func (queue *Queue) GoString() string { return queue.String() }
func derive(key []byte, label string) []byte {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(label))
return mac.Sum(nil)
}
func New(db *sql.DB, options Options) (*Queue, error) {
if db == nil || len(options.EncryptionKey) != 32 {
return nil, ErrInvalid
}
if options.MaxPending == 0 {
options.MaxPending = 1000
}
if options.MaxPending < 1 || options.MaxPending > 10000 {
return nil, ErrInvalid
}
if options.Now == nil {
options.Now = time.Now
}
block, err := aes.NewCipher(derive(options.EncryptionKey, "gwf.mail.encryption.v1"))
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Queue{db: db, aead: aead, digestKey: derive(options.EncryptionKey, "gwf.mail.deduplication.v1"), maxPending: options.MaxPending, now: options.Now}, nil
}
// CreateSchema is called inside the application's explicit migration, never
// request startup. A caller-owned transaction also makes schema creation atomic.
func CreateSchema(ctx context.Context, tx *sql.Tx) error {
if tx == nil {
return ErrInvalid
}
for _, statement := range []string{
`CREATE TABLE IF NOT EXISTS gwf_mail_outbox (
id TEXT PRIMARY KEY,state TEXT NOT NULL CHECK(state IN ('queued','sending','accepted','failed','expired')),
payload BLOB,digest BLOB NOT NULL CHECK(length(digest)=32),created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL,
next_attempt_at INTEGER NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,lease_hash BLOB,lease_until INTEGER NOT NULL DEFAULT 0,
last_stage TEXT NOT NULL DEFAULT '',smtp_code INTEGER NOT NULL DEFAULT 0,updated_at INTEGER NOT NULL,
CHECK(expires_at>created_at),CHECK(attempts>=0),CHECK(lease_hash IS NULL OR length(lease_hash)=32))`,
`CREATE INDEX IF NOT EXISTS gwf_mail_outbox_ready ON gwf_mail_outbox(state,next_attempt_at,created_at,id)`,
`CREATE INDEX IF NOT EXISTS gwf_mail_outbox_expiry ON gwf_mail_outbox(expires_at)`,
} {
if _, err := tx.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
type payload struct {
Version int
Message mail.Message
ExpiresAt time.Time
}
// EnqueueTx commits mail intent alongside the caller's domain change and audit.
// It never commits, sends SMTP or logs plaintext. Reusing an ID is idempotent only
// for the same message/expiry; a completed ID cannot send a second time.
func (queue *Queue) EnqueueTx(ctx context.Context, tx *sql.Tx, message mail.Message, expiresAt time.Time) error {
if tx == nil || message.Validate() != nil {
return ErrInvalid
}
now := queue.now().UTC()
expiresAt = expiresAt.UTC().Truncate(time.Second)
message.CreatedAt = message.CreatedAt.UTC().Truncate(time.Second)
if !expiresAt.After(now) || expiresAt.Sub(now) > 24*time.Hour || !expiresAt.After(message.CreatedAt) || message.CreatedAt.After(now.Add(time.Minute)) {
return ErrInvalid
}
raw, err := json.Marshal(payload{Version: 1, Message: message, ExpiresAt: expiresAt})
if err != nil {
return err
}
mac := hmac.New(sha256.New, queue.digestKey)
mac.Write(raw)
digest := mac.Sum(nil)
nonce := make([]byte, queue.aead.NonceSize())
if _, err = rand.Read(nonce); err != nil {
return err
}
sealed := append([]byte{1}, nonce...)
sealed = queue.aead.Seal(sealed, nonce, raw, []byte("gwf.mail.v1:"+message.ID))
// First statement acquires SQLite's writer lock, avoiding read/write upgrade
// races when multiple requests enqueue or compete for the bounded capacity.
var inserted string
err = tx.QueryRowContext(ctx, `INSERT INTO gwf_mail_outbox(id,state,payload,digest,created_at,expires_at,next_attempt_at,updated_at)
SELECT ?,'queued',?,?,?,?,?,? WHERE (SELECT COUNT(*) FROM gwf_mail_outbox WHERE payload IS NOT NULL)<?
ON CONFLICT(id) DO NOTHING RETURNING id`, message.ID, sealed, digest, message.CreatedAt.Unix(), expiresAt.Unix(), now.Unix(), now.Unix(), queue.maxPending).Scan(&inserted)
if err == nil {
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
var existing []byte
if err = tx.QueryRowContext(ctx, `SELECT digest FROM gwf_mail_outbox WHERE id=?`, message.ID).Scan(&existing); errors.Is(err, sql.ErrNoRows) {
return ErrFull
} else if err != nil {
return err
}
if !hmac.Equal(existing, digest) {
return ErrConflict
}
return nil
}
func (queue *Queue) Enqueue(ctx context.Context, message mail.Message, expiresAt time.Time) error {
tx, err := queue.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = queue.EnqueueTx(ctx, tx, message, expiresAt); err != nil {
return err
}
return tx.Commit()
}
// Record is safe operational metadata; it contains no recipient/body/token.
// Expose it only through the application's current operator authority.
type Record struct {
ID, State, FailureStage string
SMTPCode, Attempts int
CreatedAt, ExpiresAt, NextAttemptAt time.Time
}
func (queue *Queue) Recent(ctx context.Context, limit int) ([]Record, error) {
if limit < 1 || limit > 100 {
return nil, ErrInvalid
}
rows, err := queue.db.QueryContext(ctx, `SELECT id,state,last_stage,smtp_code,attempts,created_at,expires_at,next_attempt_at FROM gwf_mail_outbox ORDER BY created_at DESC,id DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []Record
for rows.Next() {
var record Record
var created, expires, next int64
if err = rows.Scan(&record.ID, &record.State, &record.FailureStage, &record.SMTPCode, &record.Attempts, &created, &expires, &next); err != nil {
return nil, err
}
record.CreatedAt = time.Unix(created, 0).UTC()
record.ExpiresAt = time.Unix(expires, 0).UTC()
if next > 0 && record.State == "queued" {
record.NextAttemptAt = time.Unix(next, 0).UTC()
}
records = append(records, record)
}
return records, rows.Err()
}
// Sweep clears sensitive payloads for expired/exhausted work in batches of 100.
// A running, unexpired lease is left alone. Metadata/deduplication tombstones stay.
func (queue *Queue) Sweep(ctx context.Context) error {
now := queue.now().UTC().Unix()
_, err := queue.db.ExecContext(ctx, `UPDATE gwf_mail_outbox SET state=CASE WHEN expires_at<=? THEN 'expired' ELSE 'failed' END,
payload=NULL,lease_hash=NULL,lease_until=0,next_attempt_at=0,updated_at=?,last_stage=CASE WHEN expires_at<=? THEN 'expired' ELSE 'attempt-limit' END
WHERE id IN (SELECT id FROM gwf_mail_outbox WHERE payload IS NOT NULL AND (expires_at<=? OR attempts>=?) AND (state<>'sending' OR lease_until<=?) ORDER BY expires_at,id LIMIT 100)`, now, now, now, now, maxAttempts, now)
return err
}
func (queue *Queue) open(id string, sealed []byte, expires int64) (mail.Message, error) {
n := queue.aead.NonceSize()
if len(sealed) < 1+n+queue.aead.Overhead() || sealed[0] != 1 {
return mail.Message{}, ErrInvalid
}
raw, err := queue.aead.Open(nil, sealed[1:1+n], sealed[1+n:], []byte("gwf.mail.v1:"+id))
if err != nil {
return mail.Message{}, ErrInvalid
}
var body payload
if json.Unmarshal(raw, &body) != nil || body.Version != 1 || body.Message.ID != id || body.ExpiresAt.Unix() != expires || body.Message.Validate() != nil {
return mail.Message{}, ErrInvalid
}
return body.Message, nil
}
// ProcessOne takes one bounded lease, commits it, then contacts SMTP without a
// database write lock. It records safe status and clears terminal payloads. A
// crash/uncertain acceptance can duplicate delivery; stable IDs aid diagnosis.
// false means no ready work. A delivery failure is a Record, not a raw SMTP error.
func (queue *Queue) ProcessOne(ctx context.Context, transport mail.Transport) (Record, bool, error) {
if transport == nil {
return Record{}, false, ErrInvalid
}
if err := queue.Sweep(ctx); err != nil {
return Record{}, false, err
}
now := queue.now().UTC()
lease := make([]byte, 32)
if _, err := rand.Read(lease); err != nil {
return Record{}, false, err
}
var record Record
var sealed []byte
var created, expires int64
err := queue.db.QueryRowContext(ctx, `UPDATE gwf_mail_outbox SET state='sending',attempts=attempts+1,lease_hash=?,lease_until=?,updated_at=?
WHERE id=(SELECT id FROM gwf_mail_outbox WHERE payload IS NOT NULL AND expires_at>? AND next_attempt_at<=? AND attempts<?
AND (state='queued' OR (state='sending' AND lease_until<=?)) ORDER BY created_at,id LIMIT 1)
RETURNING id,payload,attempts,created_at,expires_at`, lease, now.Add(leaseDuration).Unix(), now.Unix(), now.Unix(), now.Unix(), maxAttempts, now.Unix()).Scan(&record.ID, &sealed, &record.Attempts, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, false, nil
}
if err != nil {
return Record{}, false, err
}
record.CreatedAt = time.Unix(created, 0).UTC()
record.ExpiresAt = time.Unix(expires, 0).UTC()
message, sendErr := queue.open(record.ID, sealed, expires)
payloadInvalid := sendErr != nil
if sendErr == nil {
// End transmission before the lease expires. Also stop when the message's
// own deadline expires; no stale verification link starts after expiry.
duration := min(30*time.Second, record.ExpiresAt.Sub(queue.now().UTC()))
if duration <= 0 {
sendErr = &mail.Error{Stage: "expired"}
} else {
sendContext, cancel := context.WithTimeout(ctx, duration)
sendErr = transport.Send(sendContext, message)
cancel()
}
}
finished := queue.now().UTC()
record.State = "accepted"
if payloadInvalid {
// A wrong key is an operational fault, not a delivery attempt. Preserve
// ciphertext until expiry so fixing configuration can recover pending
// messages. The same bounded delay also covers corrupt/invalid payloads;
// never pass them to SMTP or persist the decryption error.
record.Attempts--
record.State, record.FailureStage = "queued", "payload"
record.NextAttemptAt = finished.Add(5 * time.Minute)
if !record.ExpiresAt.After(finished) {
record.State = "expired"
}
} else if sendErr != nil {
stage, code, retry := "transport", 0, true
var failure *mail.Error
if errors.As(sendErr, &failure) {
stage, code, retry = failure.Stage, failure.Code, failure.Retryable
}
// Only bounded known stages may enter durable diagnostics, even for a
// caller-provided Transport. Never persist arbitrary error strings.
switch stage {
case "connect", "tls", "greeting", "starttls-required", "authentication", "sender", "recipient", "data", "acceptance", "payload", "expired":
default:
stage = "transport"
}
if code < 0 || code > 599 {
code = 0
}
record.FailureStage, record.SMTPCode = stage, code
record.State = "failed"
record.NextAttemptAt = finished.Add(time.Minute * time.Duration(1<<(record.Attempts-1)))
if !record.ExpiresAt.After(finished) || stage == "expired" {
record.State = "expired"
} else if retry && record.Attempts < maxAttempts && record.NextAttemptAt.Before(record.ExpiresAt) {
record.State = "queued"
}
}
var retain []byte
var nextAttempt int64
if record.State == "queued" {
retain = sealed
nextAttempt = record.NextAttemptAt.Unix()
} else {
record.NextAttemptAt = time.Time{}
}
result, err := queue.db.ExecContext(ctx, `UPDATE gwf_mail_outbox SET state=?,payload=?,last_stage=?,smtp_code=?,attempts=?,next_attempt_at=?,lease_hash=NULL,lease_until=0,updated_at=? WHERE id=? AND state='sending' AND lease_hash=? AND lease_until>?`, record.State, retain, record.FailureStage, record.SMTPCode, record.Attempts, nextAttempt, finished.Unix(), record.ID, lease, finished.Unix())
if err != nil {
return record, true, err
}
changed, err := result.RowsAffected()
if err != nil {
return record, true, err
}
if changed != 1 {
return record, true, ErrConflict
}
return record, true, nil
}
+378
View File
@@ -0,0 +1,378 @@
// SPDX-License-Identifier: MPL-2.0
package mailsqlite
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"gamertan.com/web/mail"
_ "modernc.org/sqlite"
)
type testClock struct{ seconds atomic.Int64 }
func (clock *testClock) now() time.Time { return time.Unix(clock.seconds.Load(), 0).UTC() }
func (clock *testClock) advance(duration time.Duration) {
clock.seconds.Add(int64(duration / time.Second))
}
type transportFunc func(context.Context, mail.Message) error
func (send transportFunc) Send(ctx context.Context, message mail.Message) error {
return send(ctx, message)
}
func fixture(t *testing.T, capacity int) (*Queue, *sql.DB, *testClock) {
t.Helper()
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "mail.sqlite"))+"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)")
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(8)
t.Cleanup(func() { db.Close() })
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if err = CreateSchema(context.Background(), tx); err != nil {
t.Fatal(err)
}
if err = CreateSchema(context.Background(), tx); err != nil {
t.Fatal(err)
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
clock := &testClock{}
clock.seconds.Store(time.Date(2026, 9, 11, 6, 0, 0, 0, time.UTC).Unix())
queue, err := New(db, Options{EncryptionKey: bytes.Repeat([]byte{17}, 32), MaxPending: capacity, Now: clock.now})
if err != nil {
t.Fatal(err)
}
return queue, db, clock
}
func messageAt(clock *testClock, number int) mail.Message {
return mail.Message{ID: fmt.Sprintf("mail_message_%016d", number), To: "recipient@example.test", Subject: "Confirm your email", Text: "Private recovery token: only-in-ciphertext-123", CreatedAt: clock.now()}
}
func enqueue(t *testing.T, queue *Queue, clock *testClock, number int) mail.Message {
t.Helper()
message := messageAt(clock, number)
if err := queue.Enqueue(context.Background(), message, clock.now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
return message
}
func storedPayload(t *testing.T, db *sql.DB, id string) []byte {
t.Helper()
var sealed []byte
if err := db.QueryRow(`SELECT payload FROM gwf_mail_outbox WHERE id=?`, id).Scan(&sealed); err != nil {
t.Fatal(err)
}
return sealed
}
func TestTransactionalEncryptionAndDeduplication(t *testing.T) {
queue, db, clock := fixture(t, 1)
ctx := context.Background()
message := messageAt(clock, 1)
expires := clock.now().Add(time.Hour)
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if _, err = tx.Exec(`CREATE TABLE domain_change(id TEXT)`); err != nil {
t.Fatal(err)
}
if _, err = tx.Exec(`INSERT INTO domain_change VALUES ('changed')`); err != nil {
t.Fatal(err)
}
if err = queue.EnqueueTx(ctx, tx, message, expires); err != nil {
t.Fatal(err)
}
if err = tx.Rollback(); err != nil {
t.Fatal(err)
}
var count int
if err = db.QueryRow(`SELECT COUNT(*) FROM gwf_mail_outbox`).Scan(&count); err != nil || count != 0 {
t.Fatalf("rollback count=%d err=%v", count, err)
}
if err = db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name='domain_change'`).Scan(&count); err != nil || count != 0 {
t.Fatalf("domain rollback count=%d err=%v", count, err)
}
if err = queue.Enqueue(ctx, message, expires); err != nil {
t.Fatal(err)
}
sealed := storedPayload(t, db, message.ID)
if len(sealed) == 0 || bytes.Contains(sealed, []byte(message.To)) || bytes.Contains(sealed, []byte(message.Text)) {
t.Fatal("payload is not encrypted")
}
if err = queue.Enqueue(ctx, message, expires); err != nil {
t.Fatalf("idempotent at capacity: %v", err)
}
changed := message
changed.Text = "Different content"
if err = queue.Enqueue(ctx, changed, expires); !errors.Is(err, ErrConflict) {
t.Fatalf("conflicting content: %v", err)
}
if err = queue.Enqueue(ctx, message, expires.Add(time.Minute)); !errors.Is(err, ErrConflict) {
t.Fatalf("conflicting expiry: %v", err)
}
if err = queue.Enqueue(ctx, messageAt(clock, 2), expires); !errors.Is(err, ErrFull) {
t.Fatalf("capacity: %v", err)
}
var calls int
send := transportFunc(func(_ context.Context, got mail.Message) error {
calls++
if got.ID != message.ID || got.To != message.To || got.Text != message.Text {
t.Fatal("message changed")
}
return nil
})
record, work, err := queue.ProcessOne(ctx, send)
if err != nil || !work || record.State != "accepted" || record.Attempts != 1 || !record.NextAttemptAt.IsZero() {
t.Fatalf("accept: %+v work=%v err=%v", record, work, err)
}
if storedPayload(t, db, message.ID) != nil {
t.Fatal("accepted payload retained")
}
if err = queue.Enqueue(ctx, message, expires); err != nil {
t.Fatal(err)
}
if _, work, err = queue.ProcessOne(ctx, send); err != nil || work || calls != 1 {
t.Fatalf("completed ID sent again: calls=%d work=%v err=%v", calls, work, err)
}
records, err := queue.Recent(ctx, 10)
if err != nil || len(records) != 1 || records[0].State != "accepted" || !records[0].NextAttemptAt.IsZero() {
t.Fatalf("metadata: %+v %v", records, err)
}
if err = queue.Enqueue(ctx, messageAt(clock, 2), expires); err != nil {
t.Fatalf("released capacity: %v", err)
}
}
func TestConcurrentCapacity(t *testing.T) {
queue, _, clock := fixture(t, 1)
var group sync.WaitGroup
var wins, full atomic.Int32
for number := range 8 {
group.Go(func() {
err := queue.Enqueue(context.Background(), messageAt(clock, number), clock.now().Add(time.Hour))
if err == nil {
wins.Add(1)
} else if errors.Is(err, ErrFull) {
full.Add(1)
} else {
t.Errorf("enqueue: %v", err)
}
})
}
group.Wait()
if wins.Load() != 1 || full.Load() != 7 {
t.Fatalf("wins=%d full=%d", wins.Load(), full.Load())
}
}
func TestRetryBoundsAndSafeDiagnostics(t *testing.T) {
queue, db, clock := fixture(t, 0)
message := enqueue(t, queue, clock, 1)
send := transportFunc(func(context.Context, mail.Message) error {
return &mail.Error{Stage: "recipient", Code: 450, Retryable: true}
})
for attempt := 1; attempt <= maxAttempts; attempt++ {
record, work, err := queue.ProcessOne(context.Background(), send)
if err != nil || !work || record.Attempts != attempt || record.FailureStage != "recipient" || record.SMTPCode != 450 {
t.Fatalf("attempt %d: %+v %v %v", attempt, record, work, err)
}
if attempt == maxAttempts {
if record.State != "failed" || storedPayload(t, db, message.ID) != nil {
t.Fatal("exhausted payload not cleared")
}
break
}
delay := time.Minute * time.Duration(1<<(attempt-1))
if record.State != "queued" || !record.NextAttemptAt.Equal(clock.now().Add(delay)) {
t.Fatalf("retry schedule: %+v", record)
}
if _, work, err = queue.ProcessOne(context.Background(), send); err != nil || work {
t.Fatalf("early retry: %v %v", work, err)
}
clock.advance(delay)
}
enqueue(t, queue, clock, 2)
record, _, err := queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error {
return &mail.Error{Stage: "secret-recipient@example.test", Code: 9999, Retryable: false}
}))
if err != nil || record.State != "failed" || record.FailureStage != "transport" || record.SMTPCode != 0 {
t.Fatalf("unsafe diagnostic: %+v %v", record, err)
}
enqueue(t, queue, clock, 3)
record, _, err = queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error { return errors.New("contains private token and address") }))
if err != nil || record.State != "queued" || record.FailureStage != "transport" {
t.Fatalf("raw transport error: %+v %v", record, err)
}
}
func TestExpiryAndCiphertextRecovery(t *testing.T) {
queue, db, clock := fixture(t, 0)
message := enqueue(t, queue, clock, 1)
sealed := storedPayload(t, db, message.ID)
wrong, err := New(db, Options{EncryptionKey: bytes.Repeat([]byte{18}, 32), Now: clock.now})
if err != nil {
t.Fatal(err)
}
var calls int
send := transportFunc(func(context.Context, mail.Message) error { calls++; return nil })
record, work, err := wrong.ProcessOne(context.Background(), send)
if err != nil || !work || record.State != "queued" || record.FailureStage != "payload" || record.Attempts != 0 || calls != 0 {
t.Fatalf("wrong key: %+v %v %v calls=%d", record, work, err, calls)
}
if !bytes.Equal(sealed, storedPayload(t, db, message.ID)) {
t.Fatal("key error destroyed pending ciphertext")
}
clock.advance(5 * time.Minute)
record, work, err = queue.ProcessOne(context.Background(), send)
if err != nil || !work || record.State != "accepted" || record.Attempts != 1 || calls != 1 {
t.Fatalf("key recovery: %+v %v %v calls=%d", record, work, err, calls)
}
message = enqueue(t, queue, clock, 2)
sealed = storedPayload(t, db, message.ID)
sealed[len(sealed)-1] ^= 1
if _, err = db.Exec(`UPDATE gwf_mail_outbox SET payload=? WHERE id=?`, sealed, message.ID); err != nil {
t.Fatal(err)
}
record, _, err = queue.ProcessOne(context.Background(), send)
if err != nil || record.FailureStage != "payload" || calls != 1 {
t.Fatalf("tamper sent: %+v %v calls=%d", record, err, calls)
}
clock.advance(time.Hour)
if _, work, err = queue.ProcessOne(context.Background(), send); err != nil || work || calls != 1 {
t.Fatalf("expired sent: work=%v err=%v calls=%d", work, err, calls)
}
if storedPayload(t, db, message.ID) != nil {
t.Fatal("expired corrupt payload retained")
}
if _, err = queue.open(message.ID, sealed[:3], clock.now().Unix()); !errors.Is(err, ErrInvalid) {
t.Fatal("short ciphertext accepted")
}
valid := enqueue(t, queue, clock, 3)
if _, err = queue.open("another_message_identity", storedPayload(t, db, valid.ID), clock.now().Add(time.Hour).Unix()); !errors.Is(err, ErrInvalid) {
t.Fatal("AAD identity not bound")
}
if _, err = queue.open(valid.ID, storedPayload(t, db, valid.ID), clock.now().Add(2*time.Hour).Unix()); !errors.Is(err, ErrInvalid) {
t.Fatal("expiry not bound")
}
}
func TestLeaseIsolationAndLateAcknowledgement(t *testing.T) {
queue, db, clock := fixture(t, 0)
message := enqueue(t, queue, clock, 1)
entered, release := make(chan struct{}), make(chan struct{})
type result struct {
record Record
work bool
err error
}
done := make(chan result, 1)
go func() {
record, work, err := queue.ProcessOne(context.Background(), transportFunc(func(ctx context.Context, _ mail.Message) error {
close(entered)
select {
case <-release:
return nil
case <-ctx.Done():
return ctx.Err()
}
}))
done <- result{record, work, err}
}()
<-entered
if _, work, err := queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error { t.Error("duplicate active lease"); return nil })); err != nil || work {
t.Fatalf("leased work claimed: %v %v", work, err)
}
// SMTP cannot hold the SQLite writer lock while waiting on the network.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := db.ExecContext(ctx, `CREATE TABLE independent_domain_write(id TEXT)`); err != nil {
t.Fatal(err)
}
clock.advance(leaseDuration + time.Second)
record, work, err := queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error { return nil }))
if err != nil || !work || record.State != "accepted" || record.Attempts != 2 {
t.Fatalf("takeover: %+v %v %v", record, work, err)
}
close(release)
previous := <-done
if !previous.work || !errors.Is(previous.err, ErrConflict) {
t.Fatalf("late acknowledgement: %+v", previous)
}
if storedPayload(t, db, message.ID) != nil {
t.Fatal("late worker restored payload")
}
}
func TestCancellationLeavesRecoverableLease(t *testing.T) {
queue, db, clock := fixture(t, 0)
message := enqueue(t, queue, clock, 1)
ctx, cancel := context.WithCancel(context.Background())
_, work, err := queue.ProcessOne(ctx, transportFunc(func(context.Context, mail.Message) error { cancel(); return nil }))
if !work || !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled acknowledgement: %v %v", work, err)
}
if storedPayload(t, db, message.ID) == nil {
t.Fatal("uncertain delivery destroyed recovery payload")
}
clock.advance(leaseDuration + time.Second)
record, work, err := queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error { return nil }))
if err != nil || !work || record.State != "accepted" || record.Attempts != 2 {
t.Fatalf("recovery: %+v %v %v", record, work, err)
}
}
func TestValidation(t *testing.T) {
queue, db, clock := fixture(t, 0)
ctx := context.Background()
for _, options := range []Options{{}, {EncryptionKey: make([]byte, 31)}, {EncryptionKey: make([]byte, 32), MaxPending: -1}, {EncryptionKey: make([]byte, 32), MaxPending: 10001}} {
if _, err := New(db, options); !errors.Is(err, ErrInvalid) {
t.Fatalf("invalid options: %v", err)
}
}
if _, err := New(nil, Options{EncryptionKey: make([]byte, 32)}); !errors.Is(err, ErrInvalid) {
t.Fatal("nil DB accepted")
}
if err := CreateSchema(ctx, nil); !errors.Is(err, ErrInvalid) {
t.Fatal("nil migration accepted")
}
if err := queue.EnqueueTx(ctx, nil, messageAt(clock, 1), clock.now().Add(time.Hour)); !errors.Is(err, ErrInvalid) {
t.Fatal("nil tx accepted")
}
for _, expiry := range []time.Time{clock.now(), clock.now().Add(-time.Minute), clock.now().Add(25 * time.Hour)} {
if err := queue.Enqueue(ctx, messageAt(clock, 1), expiry); !errors.Is(err, ErrInvalid) {
t.Fatalf("invalid expiry: %v", err)
}
}
message := messageAt(clock, 1)
message.CreatedAt = clock.now().Add(2 * time.Minute)
if err := queue.Enqueue(ctx, message, clock.now().Add(time.Hour)); !errors.Is(err, ErrInvalid) {
t.Fatal("future message accepted")
}
for _, limit := range []int{0, 101} {
if _, err := queue.Recent(ctx, limit); !errors.Is(err, ErrInvalid) {
t.Fatal("invalid metadata limit")
}
}
if _, _, err := queue.ProcessOne(ctx, nil); !errors.Is(err, ErrInvalid) {
t.Fatal("nil transport accepted")
}
}
+57 -1
View File
@@ -20,6 +20,7 @@ import (
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
@@ -121,7 +122,7 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
}
detected := http.DetectContentType(data)
if detected == "application/pdf" && bytes.HasPrefix(data, []byte("%PDF-")) {
if detected == "application/pdf" && validPDF(data) {
result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name}
result.Digest = sha256.Sum256(result.Data)
return result, nil
@@ -156,6 +157,61 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
return result, nil
}
// validPDF performs a deliberately bounded structural check without trying to
// render or interpret document content. It rejects header-only spoofing and
// truncated uploads by requiring a supported header, terminal EOF marker, a
// numeric startxref offset, and either a traditional xref table with trailer
// or an xref-stream object at that offset.
func validPDF(data []byte) bool {
if len(data) < 32 || !bytes.HasPrefix(data, []byte("%PDF-")) {
return false
}
headerEnd := bytes.IndexAny(data, "\r\n")
if headerEnd < 8 || headerEnd > 32 {
return false
}
header := string(bytes.TrimSpace(data[:headerEnd]))
if header != "%PDF-1.0" && header != "%PDF-1.1" && header != "%PDF-1.2" && header != "%PDF-1.3" && header != "%PDF-1.4" && header != "%PDF-1.5" && header != "%PDF-1.6" && header != "%PDF-1.7" && header != "%PDF-2.0" {
return false
}
trimmed := bytes.TrimRight(data, "\x00\t\n\f\r ")
if !bytes.HasSuffix(trimmed, []byte("%%EOF")) {
return false
}
eof := len(trimmed) - len("%%EOF")
start := bytes.LastIndex(trimmed[:eof], []byte("startxref"))
if start < headerEnd {
return false
}
cursor := start + len("startxref")
for cursor < eof && (trimmed[cursor] == ' ' || trimmed[cursor] == '\t' || trimmed[cursor] == '\r' || trimmed[cursor] == '\n' || trimmed[cursor] == '\f') {
cursor++
}
digits := cursor
for cursor < eof && trimmed[cursor] >= '0' && trimmed[cursor] <= '9' && cursor-digits < 20 {
cursor++
}
if cursor == digits {
return false
}
if len(bytes.TrimSpace(trimmed[cursor:eof])) != 0 {
return false
}
offset, err := strconv.ParseInt(string(trimmed[digits:cursor]), 10, 64)
if err != nil || offset < int64(headerEnd+1) || offset >= int64(start) {
return false
}
target := trimmed[int(offset):start]
if bytes.HasPrefix(target, []byte("xref")) {
return bytes.Contains(target, []byte("trailer"))
}
lineEnd := bytes.IndexByte(target, '\n')
if lineEnd < 5 || lineEnd > 80 || !bytes.Contains(target[:lineEnd], []byte(" obj")) {
return false
}
return bytes.Contains(target, []byte("/Type /XRef")) || bytes.Contains(target, []byte("/Type/XRef"))
}
func Extension(mediaType string) string {
switch mediaType {
case "image/jpeg":
+21 -1
View File
@@ -5,6 +5,7 @@ package media
import (
"bytes"
"errors"
"fmt"
"image"
"image/color"
"image/jpeg"
@@ -33,7 +34,8 @@ func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) {
}
func TestPreparePDFIsAttachment(t *testing.T) {
prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{})
pdf := minimalPDF()
prepared, err := Prepare(bytes.NewReader(pdf), "guide.pdf", Limits{})
if err != nil {
t.Fatal(err)
}
@@ -42,6 +44,18 @@ func TestPreparePDFIsAttachment(t *testing.T) {
}
}
func TestPrepareRejectsMalformedPDF(t *testing.T) {
for _, source := range []string{
"%PDF-1.7\nsmall fixture",
"%PDF-9.9\nxref\ntrailer\nstartxref\n9\n%%EOF",
"%PDF-1.7\nxref\ntrailer\nstartxref\n999999\n%%EOF",
} {
if _, err := Prepare(strings.NewReader(source), "broken.pdf", Limits{}); !errors.Is(err, ErrInvalidMedia) {
t.Fatalf("malformed PDF error=%v source=%q", err, source)
}
}
}
func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) {
t.Fatalf("svg err=%v", err)
@@ -50,3 +64,9 @@ func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
t.Fatalf("large err=%v", err)
}
}
func minimalPDF() []byte {
prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")
offset := len(prefix)
return append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", offset))...)
}
+4 -1
View File
@@ -5,6 +5,7 @@ package medialocal
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -20,7 +21,9 @@ func TestStoreRoundTripAndIdempotentPut(t *testing.T) {
if err != nil {
t.Fatal(err)
}
prepared, err := media.Prepare(bytes.NewReader([]byte("%PDF-1.7\nfixture")), "fixture.pdf", media.Limits{})
prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")
pdf := append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(prefix)))...)
prepared, err := media.Prepare(bytes.NewReader(pdf), "fixture.pdf", media.Limits{})
if err != nil {
t.Fatal(err)
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
)
var ErrDirectoryQuery = errors.New("organizations: invalid directory query")
// DirectoryQuery searches all organizations independently of membership.
// Search is literal text. AfterID is an exclusive stable-ID cursor. Limit
// defaults to 50 and may not exceed 200.
type DirectoryQuery struct {
Search, AfterID string
Limit int
}
type DirectoryPage struct {
Organizations []Organization
NextID string
}
// DirectoryRepository is an optional administrative read capability. The caller
// MUST authorize instance-wide organization access. Personal and archived records
// are included; no membership is granted and no invitations or secrets are read.
// The application classifies its configured merchant organization. Pagination is
// a current view, not a snapshot across requests.
type DirectoryRepository interface {
OrganizationDirectory(context.Context, DirectoryQuery) (DirectoryPage, error)
}
+201 -40
View File
@@ -14,20 +14,24 @@ import (
"fmt"
"io"
"regexp"
"slices"
"strings"
"time"
)
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")
ErrOwnerAuthority = errors.New("organizations: a current direct owner must manage owner access")
ErrOwnedCreationUnsupported = errors.New("organizations: atomic owned organization creation is unsupported")
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
)
type Organization struct {
@@ -74,11 +78,16 @@ type ApplicationService struct {
}
type Invitation struct {
ID string
Digest [32]byte
OrganizationID string
Email, InvitedByUserID string
DirectRole string
ID string
Digest [32]byte
OrganizationID string
Email, InvitedByUserID string
// DirectRole is the legacy single-role form. Use exactly one form.
DirectRole string
DirectRoles []string
// RequiredOwnerRole records the grantor authority to recheck at acceptance.
// Services set it from their trusted configuration, never a request payload.
RequiredOwnerRole string
TeamIDs []string
CreatedAt, ExpiresAt, UsedAt, RevokedAt time.Time
}
@@ -102,26 +111,41 @@ type Repository interface {
CreateProject(context.Context, Project) error
CreateEnvironment(context.Context, Environment) error
CreateApplicationService(context.Context, ApplicationService) error
CreateInvitation(context.Context, Invitation, AuditEvent) error
CreateInvitation(context.Context, Invitation, string, AuditEvent) error
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
Invitations(context.Context, string, int) ([]Invitation, error)
RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error
RevokeInvitation(context.Context, string, 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
OwnerRole string
// OwnerManagedInvitations requires a current direct owner to create, revoke,
// and remain the grantor of an invitation until it is accepted.
OwnerManagedInvitations bool
}
type Service struct {
repository Repository
random io.Reader
now func() time.Time
ownerRole string
repository Repository
random io.Reader
now func() time.Time
ownerRole string
ownerManagedInvitations bool
}
func New(repository Repository, options Options) (*Service, error) {
@@ -134,31 +158,20 @@ func New(repository Repository, options Options) (*Service, error) {
if options.Now == nil {
options.Now = time.Now
}
if options.OwnerRole != "" && !safeNamePattern.MatchString(options.OwnerRole) {
if options.OwnerRole != "" && !safeNamePattern.MatchString(options.OwnerRole) || options.OwnerManagedInvitations && options.OwnerRole == "" {
return nil, errors.New("organizations: owner role is invalid")
}
return &Service{repository: repository, random: options.Random, now: options.Now, ownerRole: options.OwnerRole}, nil
return &Service{repository: repository, random: options.Random, now: options.Now, ownerRole: options.OwnerRole, ownerManagedInvitations: options.OwnerManagedInvitations}, nil
}
type CreateOrganization struct {
Slug, Name, OwnerUserID string
Personal bool
RequestID string
}
func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
input.Name = strings.TrimSpace(input.Name)
if !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || !idPattern.MatchString(input.OwnerUserID) {
return Organization{}, errors.New("organizations: invalid organization")
}
id, err := token(service.random, 18)
if err != nil {
return Organization{}, err
}
now := service.now().UTC()
organization := Organization{ID: id, Slug: input.Slug, Name: input.Name, Status: "active", Personal: input.Personal, Revision: 1, CreatedAt: now, UpdatedAt: now}
owner := Membership{OrganizationID: id, UserID: input.OwnerUserID, Status: "active", JoinedAt: now}
audit, err := service.audit(input.OwnerUserID, id, "organization.create", "organization", id, "Organization created")
organization, owner, audit, err := service.prepareOrganization(input)
if err != nil {
return Organization{}, err
}
@@ -168,6 +181,26 @@ func (service *Service) CreateOrganization(ctx context.Context, input CreateOrga
return organization, nil
}
func (service *Service) prepareOrganization(input CreateOrganization) (Organization, Membership, AuditEvent, error) {
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
input.Name = strings.TrimSpace(input.Name)
if !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || !idPattern.MatchString(input.OwnerUserID) || !boundedOptional(input.RequestID, 128) {
return Organization{}, Membership{}, AuditEvent{}, errors.New("organizations: invalid organization")
}
id, err := token(service.random, 18)
if err != nil {
return Organization{}, Membership{}, AuditEvent{}, err
}
now := service.now().UTC()
organization := Organization{ID: id, Slug: input.Slug, Name: input.Name, Status: "active", Personal: input.Personal, Revision: 1, CreatedAt: now, UpdatedAt: now}
owner := Membership{OrganizationID: id, UserID: input.OwnerUserID, Status: "active", JoinedAt: now}
audit, err := service.auditWithRequest(input.OwnerUserID, id, "organization.create", "organization", id, input.RequestID, "Organization created")
if err != nil {
return Organization{}, Membership{}, AuditEvent{}, err
}
return organization, owner, audit, nil
}
func (service *Service) CreatePersonalOrganization(ctx context.Context, userID, displayName string) (Organization, error) {
value := make([]byte, 6)
if _, err := io.ReadFull(service.random, value); err != nil {
@@ -276,6 +309,8 @@ func (service *Service) Invite(ctx context.Context, organizationID, email, invit
type InviteWithAccess struct {
OrganizationID, Email, InvitedByUserID, DirectRole string
DirectRoles []string
RequestID string
TeamIDs []string
Lifetime time.Duration
}
@@ -284,9 +319,17 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
organizationID, email, invitedBy, lifetime := input.OrganizationID, input.Email, input.InvitedByUserID, input.Lifetime
email = strings.ToLower(strings.TrimSpace(email))
input.DirectRole = strings.TrimSpace(input.DirectRole)
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour || input.DirectRole != "" && !safeNamePattern.MatchString(input.DirectRole) || !validIDs(input.TeamIDs, 16) {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour || input.DirectRole != "" && !safeNamePattern.MatchString(input.DirectRole) || !validIDs(input.TeamIDs, 16) || !boundedOptional(input.RequestID, 128) {
return "", Invitation{}, errors.New("organizations: invalid invitation")
}
roles, err := (Invitation{DirectRole: input.DirectRole, DirectRoles: input.DirectRoles}).RoleNames()
if err != nil {
return "", Invitation{}, err
}
roleRepository, roleSupport := service.repository.(RoleInvitationRepository)
if (len(input.DirectRoles) > 0 || service.ownerManagedInvitations || service.ownerRole != "" && slices.Contains(roles, service.ownerRole)) && !roleSupport {
return "", Invitation{}, ErrRoleInvitationUnsupported
}
id, err := token(service.random, 18)
if err != nil {
return "", Invitation{}, err
@@ -297,11 +340,22 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
}
now := service.now().UTC()
invitation := Invitation{ID: id, Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, DirectRole: input.DirectRole, TeamIDs: append([]string(nil), input.TeamIDs...), CreatedAt: now, ExpiresAt: now.Add(lifetime)}
audit, err := service.audit(invitedBy, organizationID, "invitation.create", "invitation", id, "Organization invitation created")
if len(input.DirectRoles) > 0 {
invitation.DirectRoles = roles
}
if service.ownerManagedInvitations || service.ownerRole != "" && slices.Contains(roles, service.ownerRole) {
invitation.RequiredOwnerRole = service.ownerRole
}
audit, err := service.auditWithRequest(invitedBy, organizationID, "invitation.create", "invitation", id, input.RequestID, "Organization invitation created")
if err != nil {
return "", Invitation{}, err
}
if err = service.repository.CreateInvitation(ctx, invitation, audit); err != nil {
if roleSupport {
err = roleRepository.CreateInvitationWithRoles(ctx, invitation, service.ownerRole, audit)
} else {
err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit)
}
if err != nil {
return "", Invitation{}, err
}
return raw, invitation, nil
@@ -321,6 +375,12 @@ func (service *Service) AcceptInvitation(ctx context.Context, rawToken, userID s
if err != nil {
return err
}
if repository, ok := service.repository.(RoleInvitationRepository); ok {
return repository.AcceptInvitationWithRoles(ctx, digest, userID, service.ownerRole, now, audit)
}
if len(invitation.DirectRoles) > 0 || invitation.RequiredOwnerRole != "" || service.ownerManagedInvitations {
return ErrRoleInvitationUnsupported
}
return service.repository.AcceptInvitation(ctx, digest, userID, now, audit)
}
@@ -331,6 +391,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")
@@ -344,6 +414,14 @@ type UpdateOrganization struct {
}
func (service *Service) UpdateOrganization(ctx context.Context, input UpdateOrganization) (Organization, error) {
return service.updateOrganization(ctx, input, false)
}
func (service *Service) updateOrganization(ctx context.Context, input UpdateOrganization, requireOwner bool) (Organization, error) {
ownedRepository, ownedSupported := service.repository.(OwnerManagedRepository)
if requireOwner && (!ownedSupported || service.ownerRole == "") {
return Organization{}, ErrOwnedManagementUnsupported
}
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
if !idPattern.MatchString(input.ID) || !idPattern.MatchString(input.ActorUserID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || input.ExpectedRevision < 1 || !boundedOptional(input.RequestID, 128) {
return Organization{}, errors.New("organizations: invalid organization update")
@@ -352,12 +430,20 @@ func (service *Service) UpdateOrganization(ctx context.Context, input UpdateOrga
if err != nil {
return Organization{}, err
}
if requireOwner && value.Personal {
return Organization{}, ErrPersonalOrganization
}
value.Slug, value.Name, value.Revision, value.UpdatedAt = input.Slug, input.Name, input.ExpectedRevision+1, service.now().UTC()
audit, err := service.auditWithRequest(input.ActorUserID, value.ID, "organization.update", "organization", value.ID, input.RequestID, "Organization details updated")
if err != nil {
return Organization{}, err
}
if err = service.repository.UpdateOrganization(ctx, value, input.ExpectedRevision, audit); err != nil {
if requireOwner {
err = ownedRepository.UpdateOwnedOrganization(ctx, value, input.ExpectedRevision, service.ownerRole, audit)
} else {
err = service.repository.UpdateOrganization(ctx, value, input.ExpectedRevision, audit)
}
if err != nil {
return Organization{}, err
}
return value, nil
@@ -453,6 +539,45 @@ 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 {
return service.changeMembershipStatus(ctx, input, false)
}
func (service *Service) changeMembershipStatus(ctx context.Context, input MembershipStatusChange, requireOwner bool) 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")
}
ownedRepository, ownedSupported := service.repository.(OwnerManagedRepository)
if requireOwner && !ownedSupported {
return ErrOwnedManagementUnsupported
}
repository, ok := service.repository.(OptimisticMembershipRepository)
if !requireOwner && !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
}
if requireOwner {
return ownedRepository.ChangeOwnedMembershipStatus(ctx, input, service.ownerRole, audit)
}
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 +592,42 @@ 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 {
return service.removeMembershipIfCurrent(ctx, input, false)
}
func (service *Service) removeMembershipIfCurrent(ctx context.Context, input MembershipRemoval, requireOwner bool) 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")
}
ownedRepository, ownedSupported := service.repository.(OwnerManagedRepository)
if requireOwner && !ownedSupported {
return ErrOwnedManagementUnsupported
}
repository, ok := service.repository.(OptimisticMembershipRepository)
if !requireOwner && !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
}
if requireOwner {
return ownedRepository.RemoveOwnedMembershipIfCurrent(ctx, input, service.ownerRole, audit)
}
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")
@@ -483,7 +644,7 @@ func (service *Service) RevokeInvitation(ctx context.Context, organizationID, in
if err != nil {
return err
}
return service.repository.RevokeInvitation(ctx, organizationID, invitationID, now, audit)
return service.repository.RevokeInvitation(ctx, organizationID, invitationID, service.ownerRole, now, audit)
}
func (service *Service) Repository() Repository { return service.repository }
+19 -2
View File
@@ -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 {
@@ -86,7 +100,7 @@ func (*repositoryStub) CreateEnvironment(context.Context, Environment) error { r
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
return nil
}
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ AuditEvent) error {
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ string, _ AuditEvent) error {
repository.invitation = invitation
return nil
}
@@ -99,7 +113,7 @@ func (repository *repositoryStub) InvitationByDigest(context.Context, [32]byte,
func (*repositoryStub) Invitations(context.Context, string, int) ([]Invitation, error) {
return nil, nil
}
func (*repositoryStub) RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error {
func (*repositoryStub) RevokeInvitation(context.Context, string, string, string, time.Time, AuditEvent) error {
return nil
}
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time, _ 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 }
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
"gamertan.com/web/access"
)
// OwnedOrganization is one atomic creation command. Implementations must commit
// the organization, membership, direct owner binding, and both audits together.
type OwnedOrganization struct {
Organization Organization
Membership Membership
OwnerBinding access.Binding
OrganizationAudit AuditEvent
AccessAudit access.AuditEvent
}
// OwnedOrganizationRepository extends Repository without changing the legacy
// membership-only CreateOrganization contract. There is no non-atomic fallback.
type OwnedOrganizationRepository interface {
CreateOwnedOrganization(context.Context, OwnedOrganization) error
}
var ErrOwnedManagementUnsupported = errors.New("organizations: atomic owner-managed updates are unsupported")
// OwnerManagedRepository rechecks the configured direct owner in the same
// transaction as profile and membership writes. These explicit operations do
// not change legacy methods used by applications with delegated administrators.
// Implementations must also preserve optimistic state, last-owner protection,
// and audit atomicity. There is no preflight-only fallback.
type OwnerManagedRepository interface {
UpdateOwnedOrganization(context.Context, Organization, int64, string, AuditEvent) error
ChangeOwnedMembershipStatus(context.Context, MembershipStatusChange, string, AuditEvent) error
RemoveOwnedMembershipIfCurrent(context.Context, MembershipRemoval, string, AuditEvent) error
}
// UpdateOwnedOrganization changes a non-personal organization's name and slug.
// OwnerRole comes from trusted service configuration, not submitted form data.
func (service *Service) UpdateOwnedOrganization(ctx context.Context, input UpdateOrganization) (Organization, error) {
return service.updateOrganization(ctx, input, true)
}
// ChangeOwnedMembershipStatus requires a current owner even when the target
// member is not an owner. It preserves the last active owner.
func (service *Service) ChangeOwnedMembershipStatus(ctx context.Context, input MembershipStatusChange) error {
return service.changeMembershipStatus(ctx, input, true)
}
// RemoveOwnedMembershipIfCurrent removes only the displayed membership state,
// with current owner authority checked in the write transaction.
func (service *Service) RemoveOwnedMembershipIfCurrent(ctx context.Context, input MembershipRemoval) error {
return service.removeMembershipIfCurrent(ctx, input, true)
}
// CreateOwnedOrganization grants the configured OwnerRole to the initial owner
// inside the creation transaction. Applications authorize creation and choose
// OwnerRole when constructing the service, never from a submitted role name.
// The role must already be seeded in the repository.
func (service *Service) CreateOwnedOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
if service.ownerRole == "" {
return Organization{}, errors.New("organizations: owned creation requires a configured owner role")
}
repository, ok := service.repository.(OwnedOrganizationRepository)
if !ok {
return Organization{}, ErrOwnedCreationUnsupported
}
organization, membership, audit, err := service.prepareOrganization(input)
if err != nil {
return Organization{}, err
}
bindingID, err := token(service.random, 18)
if err != nil {
return Organization{}, err
}
accessAuditID, err := token(service.random, 18)
if err != nil {
return Organization{}, err
}
binding := access.Binding{
ID: bindingID, SubjectKind: access.User, SubjectID: input.OwnerUserID,
Role: service.ownerRole, Scope: access.Scope{OrganizationID: organization.ID},
GrantedBy: input.OwnerUserID, GrantedAt: organization.CreatedAt,
}
accessAudit := access.AuditEvent{
ID: accessAuditID, OrganizationID: organization.ID, ActorUserID: input.OwnerUserID,
Action: "access.binding.grant", ResourceType: "binding", ResourceID: bindingID,
RequestID: input.RequestID, Summary: "Initial organization owner granted",
CreatedAt: organization.CreatedAt,
}
if err = repository.CreateOwnedOrganization(ctx, OwnedOrganization{
Organization: organization, Membership: membership, OwnerBinding: binding,
OrganizationAudit: audit, AccessAudit: accessAudit,
}); err != nil {
return Organization{}, err
}
return organization, nil
}
+113
View File
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
"strings"
"testing"
"time"
"gamertan.com/web/access"
)
type ownedRepositoryStub struct {
repositoryStub
setup OwnedOrganization
calls int
err error
}
func (repository *ownedRepositoryStub) CreateOwnedOrganization(_ context.Context, setup OwnedOrganization) error {
repository.calls++
repository.setup = setup
return repository.err
}
func TestOwnedOrganizationUsesConfiguredRoleAndAtomicRepository(t *testing.T) {
now := time.Unix(1000, 0).UTC()
repository := &ownedRepositoryStub{}
service, err := New(repository, Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
input := CreateOrganization{Slug: " CLIENT-BUSINESS ", Name: " Client Business ", OwnerUserID: "customer-12345", RequestID: "request-creation"}
organization, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
setup := repository.setup
if repository.calls != 1 || repository.organization.ID != "" || setup.Organization != organization || organization.Slug != "client-business" || organization.Name != "Client Business" {
t.Fatalf("unexpected creation: %+v", setup)
}
if setup.Membership.UserID != input.OwnerUserID || setup.OwnerBinding.SubjectKind != access.User || setup.OwnerBinding.SubjectID != input.OwnerUserID || setup.OwnerBinding.Role != "customer.owner" || setup.OwnerBinding.Scope != (access.Scope{OrganizationID: organization.ID}) || setup.OwnerBinding.GrantedBy != input.OwnerUserID {
t.Fatalf("unexpected owner: %+v", setup)
}
if setup.OrganizationAudit.RequestID != input.RequestID || setup.AccessAudit.RequestID != input.RequestID || setup.AccessAudit.ResourceID != setup.OwnerBinding.ID || !setup.OwnerBinding.GrantedAt.Equal(now) {
t.Fatalf("unexpected audits: %+v", setup)
}
}
func TestOwnedOrganizationFailsWithoutAtomicSupport(t *testing.T) {
repository := &repositoryStub{}
service, _ := New(repository, Options{OwnerRole: "customer.owner"})
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345"})
if !errors.Is(err, ErrOwnedCreationUnsupported) || organization.ID != "" || repository.organization.ID != "" {
t.Fatalf("non-atomic fallback: organization=%+v err=%v", organization, err)
}
}
func TestOwnedOrganizationRejectsInvalidSetupBeforeStorage(t *testing.T) {
for _, test := range []struct {
name string
role string
request string
random string
}{
{name: "missing role", random: strings.Repeat("a", 200)},
{name: "bad request ID", role: "customer.owner", request: "request\nsecret", random: strings.Repeat("a", 200)},
{name: "random failure", role: "customer.owner", random: strings.Repeat("a", 40)},
} {
t.Run(test.name, func(t *testing.T) {
repository := &ownedRepositoryStub{}
service, err := New(repository, Options{OwnerRole: test.role, Random: strings.NewReader(test.random)})
if err != nil {
t.Fatal(err)
}
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345", RequestID: test.request})
if err == nil || organization.ID != "" || repository.calls != 0 {
t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err)
}
})
}
}
func TestOwnedOrganizationDoesNotReturnUncommittedIdentity(t *testing.T) {
want := errors.New("durability failure")
repository := &ownedRepositoryStub{err: want}
service, _ := New(repository, Options{OwnerRole: "customer.owner"})
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345"})
if !errors.Is(err, want) || organization.ID != "" || repository.calls != 1 {
t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err)
}
}
func TestOwnedManagementHasNoPreflightOnlyFallback(t *testing.T) {
service, err := New(&repositoryStub{}, Options{OwnerRole: "customer.owner"})
if err != nil {
t.Fatal(err)
}
_, err = service.UpdateOwnedOrganization(t.Context(), UpdateOrganization{ID: "organization-12345", Slug: "business", Name: "Business", ActorUserID: "customer-12345", ExpectedRevision: 1})
if !errors.Is(err, ErrOwnedManagementUnsupported) {
t.Fatalf("profile fallback: %v", err)
}
err = service.ChangeOwnedMembershipStatus(t.Context(), MembershipStatusChange{OrganizationID: "organization-12345", UserID: "member-12345", ActorUserID: "customer-12345", ExpectedStatus: "active", Status: "suspended"})
if !errors.Is(err, ErrOwnedManagementUnsupported) {
t.Fatalf("status fallback: %v", err)
}
err = service.RemoveOwnedMembershipIfCurrent(t.Context(), MembershipRemoval{OrganizationID: "organization-12345", UserID: "member-12345", ActorUserID: "customer-12345", ExpectedStatus: "active"})
if !errors.Is(err, ErrOwnedManagementUnsupported) {
t.Fatalf("removal fallback: %v", err)
}
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
"slices"
"time"
)
var ErrRoleInvitationUnsupported = errors.New("organizations: atomic role-set invitations are unsupported")
// RoleInvitationRepository must preserve the entire role set and its required
// owner authority, then commit acceptance, membership, grants and audit together.
type RoleInvitationRepository interface {
CreateInvitationWithRoles(context.Context, Invitation, string, AuditEvent) error
AcceptInvitationWithRoles(context.Context, [32]byte, string, string, time.Time, AuditEvent) error
}
// RoleNames returns a validated copy of the invitation's direct roles. The older
// DirectRole remains supported; supplying both forms is an error, not a union.
func (invitation Invitation) RoleNames() ([]string, error) {
if invitation.DirectRole != "" && len(invitation.DirectRoles) > 0 || len(invitation.DirectRoles) > 16 {
return nil, errors.New("organizations: invalid invitation roles")
}
roles := append([]string(nil), invitation.DirectRoles...)
if invitation.DirectRole != "" {
roles = append(roles, invitation.DirectRole)
}
slices.Sort(roles)
for i, role := range roles {
if !safeNamePattern.MatchString(role) || i > 0 && role == roles[i-1] {
return nil, errors.New("organizations: invalid invitation role")
}
}
return roles, nil
}
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"crypto/sha256"
"errors"
"slices"
"strings"
"testing"
"time"
)
type roleInvitationRepositoryStub struct {
repositoryStub
audit AuditEvent
ownerRole string
created int
}
func (r *roleInvitationRepositoryStub) CreateInvitationWithRoles(_ context.Context, invitation Invitation, ownerRole string, audit AuditEvent) error {
r.created++
r.invitation, r.audit, r.ownerRole = invitation, audit, ownerRole
return nil
}
func (r *roleInvitationRepositoryStub) AcceptInvitationWithRoles(_ context.Context, _ [32]byte, userID, ownerRole string, _ time.Time, audit AuditEvent) error {
r.acceptedUser, r.ownerRole, r.audit = userID, ownerRole, audit
return nil
}
func TestInvitationRoleNamesAreBoundedAndUnambiguous(t *testing.T) {
input := []string{"buyer", "billing"}
roles, err := (Invitation{DirectRoles: input}).RoleNames()
if err != nil || !slices.Equal(roles, []string{"billing", "buyer"}) || !slices.Equal(input, []string{"buyer", "billing"}) {
t.Fatalf("roles=%v input=%v err=%v", roles, input, err)
}
for _, invitation := range []Invitation{
{DirectRole: "owner", DirectRoles: []string{"buyer"}},
{DirectRoles: []string{"buyer", "buyer"}},
{DirectRoles: []string{"not a role"}},
{DirectRoles: make([]string, 17)},
} {
if _, err := invitation.RoleNames(); err == nil {
t.Fatalf("invalid roles accepted=%+v", invitation)
}
}
if roles, err = (Invitation{DirectRole: "buyer"}).RoleNames(); err != nil || !slices.Equal(roles, []string{"buyer"}) {
t.Fatalf("legacy=%v err=%v", roles, err)
}
}
func TestRoleInvitationServicePreservesOwnerRequirementAndRequest(t *testing.T) {
r := &roleInvitationRepositoryStub{}
service, err := New(r, Options{OwnerRole: "owner", OwnerManagedInvitations: true})
if err != nil {
t.Fatal(err)
}
input := InviteWithAccess{OrganizationID: "organization-123", InvitedByUserID: "owner-12345678", Email: " Member@example.test ", DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour, RequestID: "request-invite"}
raw, invitation, err := service.InviteWithAccess(t.Context(), input)
if err != nil {
t.Fatal(err)
}
if invitation.Digest != sha256.Sum256([]byte(raw)) || invitation.RequiredOwnerRole != "owner" || r.ownerRole != "owner" || r.audit.RequestID != input.RequestID || !slices.Equal(invitation.DirectRoles, []string{"billing", "buyer"}) || invitation.Email != "member@example.test" {
t.Fatalf("invitation or audit mismatch: %+v %+v", invitation, r.audit)
}
if !slices.Equal(input.DirectRoles, []string{"buyer", "billing"}) {
t.Fatal("caller roles mutated")
}
if err = service.AcceptInvitation(t.Context(), raw, "member-12345678"); err != nil || r.ownerRole != "owner" || r.acceptedUser != "member-12345678" {
t.Fatalf("accept=%v owner=%q user=%q", err, r.ownerRole, r.acceptedUser)
}
input.RequestID = strings.Repeat("x", 129)
if _, _, err = service.InviteWithAccess(t.Context(), input); err == nil || r.created != 1 {
t.Fatal("oversized request accepted")
}
if _, err = New(r, Options{OwnerManagedInvitations: true}); err == nil {
t.Fatal("owner-managed service without owner accepted")
}
}
func TestRoleInvitationHasNoPartialLegacyFallback(t *testing.T) {
for _, input := range []InviteWithAccess{
{DirectRoles: []string{"buyer", "billing"}}, {DirectRole: "owner"}, {},
} {
r := &repositoryStub{}
service, err := New(r, Options{OwnerRole: "owner", OwnerManagedInvitations: true})
if err != nil {
t.Fatal(err)
}
input.OrganizationID, input.InvitedByUserID, input.Email, input.Lifetime = "organization-123", "owner-12345678", "member@example.test", time.Hour
if _, _, err = service.InviteWithAccess(t.Context(), input); !errors.Is(err, ErrRoleInvitationUnsupported) || r.invitation.ID != "" {
t.Fatalf("legacy fallback=%v", err)
}
r.invitation = Invitation{OrganizationID: input.OrganizationID, ID: "invitation-1234", RequiredOwnerRole: "owner"}
if err = service.AcceptInvitation(t.Context(), strings.Repeat("a", 43), "member-12345678"); !errors.Is(err, ErrRoleInvitationUnsupported) || r.acceptedUser != "" {
t.Fatalf("legacy acceptance fallback=%v", err)
}
}
}
+46
View File
@@ -5,6 +5,7 @@
.gitea/workflows/verify.yml
.gitignore
CHANGELOG.md
AI_DISCLOSURE.md
CONTRIBUTING.md
LICENSE
LICENSES.md
@@ -20,35 +21,74 @@ abuse/abuse_test.go
account/account.go
access/access.go
access/access_test.go
access/role_sets.go
access/role_sets_test.go
analytics/analytics.go
analytics/analytics_test.go
analytics/fuzz_test.go
analytics/geo.go
auth/auth.go
auth/context.go
auth/directory.go
auth/profile.go
auth/profile_test.go
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
authhttp/passkey.go
authhttp/passkey_test.go
authmail/authmail.go
authmail/authmail_test.go
authmail/README.md
authsqlite/mail.go
authsqlite/mail_test.go
authsqlite/store.go
authsqlite/store_test.go
authsqlite/directory.go
authsqlite/directory_test.go
authsqlite/profile.go
authsqlite/profile_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
authsqlite/owned_organization.go
authsqlite/owned_organization_test.go
authsqlite/owned_management_test.go
authsqlite/role_sets_test.go
authsqlite/passkey.go
authsqlite/passkey_test.go
authsqlite/recovery.go
authwebauthn/fuzz_test.go
authwebauthn/algorithm_test.go
authwebauthn/service.go
authwebauthn/service_test.go
authwebauthn/types.go
bootstrap/bootstrap.go
bootstrap/bootstrap_test.go
cms/cms.go
cms/cms_test.go
cmssqlite/schema.go
cmssqlite/terms.go
cmssqlite/associations.go
cmssqlite/store_test.go
cmssqlite/example_test.go
media/media.go
mail/mail.go
mail/smtp.go
mail/mail_test.go
mail/README.md
mailsqlite/queue.go
mailsqlite/queue_test.go
mailsqlite/README.md
media/media_test.go
medialocal/store.go
medialocal/store_test.go
@@ -56,6 +96,7 @@ internal/webauthnvendored/
docs/ADOPTION.md
docs/ARCHITECTURE.md
docs/DEPENDENCIES.md
docs/CMS.md
docs/DOGFOOD.md
docs/GETTING_STARTED.md
docs/MODULES.md
@@ -77,6 +118,11 @@ requestmeta/requestmeta.go
requestmeta/requestmeta_test.go
organizations/organizations.go
organizations/organizations_test.go
organizations/owned.go
organizations/directory.go
organizations/owned_test.go
organizations/role_invitations.go
organizations/role_invitations_test.go
scripts/check-licenses.sh
scripts/check-dependencies.sh
scripts/check-vendored-webauthn.sh
+3
View File
@@ -15,6 +15,9 @@ while IFS= read -r path; do
fi
done < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) | LC_ALL=C sort >"$temporary/expected"
diff -u "$temporary/expected" "$temporary/actual"
# The allowlist is a source distribution boundary: it must still contain the
# implementation files required by the exported packages, not just match itself.
(cd "$temporary/export" && GOWORK=off go build ./...)
private_word='PRI''VATE'
token_word='to''ken'
private_pattern="BEGIN (RSA|OPENSSH|EC) ${private_word} KEY|Authorization: ${token_word}|/home/"'cole'"|/mnt/c/"'Users'"|"'eqlwiki'"-deploy|"'crspeelman'"@gmail\\.com"