Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
262e7196fe | ||
|
|
494b72fa3b | ||
|
|
ebcbbf06f5 | ||
|
|
fcb200453a | ||
|
|
d991ad4bdb | ||
|
|
57d74bf601 | ||
|
|
a16283efd7 | ||
|
|
7c68a3499a | ||
|
|
d476179148 | ||
|
|
f142ac23a9 | ||
|
|
c0986168bc | ||
|
|
ed0cc8ceff | ||
|
|
b1710e08b8 |
@@ -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.
|
||||
+116
@@ -2,6 +2,122 @@
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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.20`. 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?
|
||||
@@ -42,8 +42,11 @@ packages you import.
|
||||
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
|
||||
| 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) |
|
||||
@@ -57,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.20
|
||||
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.20
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.28
|
||||
```
|
||||
|
||||
The version belongs to the `gamertan.com/web` module. See the
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-15
@@ -167,9 +167,28 @@ func (store *Store) OrganizationUserBindings(ctx context.Context, organizationID
|
||||
}
|
||||
|
||||
func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) error {
|
||||
if !validOrganizationRoleReplacement(expected, replacement, ownerRole, audit) {
|
||||
return errors.New("authsqlite: invalid organization role replacement")
|
||||
return store.replaceOrganizationUserRoles(ctx, expected, []access.Binding{replacement}, ownerRole, audit, false)
|
||||
}
|
||||
|
||||
func (store *Store) ReplaceOrganizationUserRoles(ctx context.Context, expected []string, replacements []access.Binding, ownerRole string, audit access.AuditEvent) error {
|
||||
return store.replaceOrganizationUserRoles(ctx, expected, replacements, ownerRole, audit, true)
|
||||
}
|
||||
|
||||
func (store *Store) replaceOrganizationUserRoles(ctx context.Context, expected []string, replacements []access.Binding, ownerRole string, audit access.AuditEvent, requireOwner bool) error {
|
||||
if len(replacements) < 1 || len(replacements) > 16 {
|
||||
return errors.New("authsqlite: invalid organization role set")
|
||||
}
|
||||
replacement := replacements[0]
|
||||
roles := make([]string, 0, len(replacements))
|
||||
ids := make(map[string]bool, len(replacements))
|
||||
for _, value := range replacements {
|
||||
if !validOrganizationRoleReplacement(expected, value, ownerRole, audit) || value.SubjectID != replacement.SubjectID || value.Scope != replacement.Scope || value.GrantedBy != replacement.GrantedBy || !value.GrantedAt.Equal(replacement.GrantedAt) || ids[value.ID] || slices.Contains(roles, value.Role) {
|
||||
return errors.New("authsqlite: invalid organization role set")
|
||||
}
|
||||
roles = append(roles, value.Role)
|
||||
ids[value.ID] = true
|
||||
}
|
||||
slices.Sort(roles)
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -183,7 +202,7 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
|
||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
|
||||
WHERE organization_id=? AND user_id=? AND status='active'
|
||||
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
|
||||
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active')`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy)
|
||||
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -195,7 +214,7 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*)
|
||||
FROM gwf_organization_memberships m
|
||||
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
|
||||
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
|
||||
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
|
||||
WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -231,10 +250,11 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
|
||||
if !slices.Equal(currentIDs, expected) {
|
||||
return access.ErrRoleChangeConflict
|
||||
}
|
||||
if len(currentRoles) == 1 && currentRoles[0] == replacement.Role {
|
||||
slices.Sort(currentRoles)
|
||||
if slices.Equal(currentRoles, roles) {
|
||||
return access.ErrRoleUnchanged
|
||||
}
|
||||
if replacement.Role == ownerRole || slices.Contains(currentRoles, ownerRole) {
|
||||
if requireOwner || slices.Contains(roles, ownerRole) || slices.Contains(currentRoles, ownerRole) {
|
||||
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, replacement.Scope.OrganizationID, replacement.GrantedBy, ownerRole)
|
||||
if ownerErr != nil {
|
||||
return ownerErr
|
||||
@@ -243,12 +263,12 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
|
||||
return access.ErrOwnerAuthority
|
||||
}
|
||||
}
|
||||
if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) {
|
||||
if !slices.Contains(roles, ownerRole) && slices.Contains(currentRoles, ownerRole) {
|
||||
var otherOwners int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
|
||||
FROM gwf_access_bindings b
|
||||
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
|
||||
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
|
||||
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
|
||||
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
|
||||
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
|
||||
AND b.revoked_at IS NULL`, replacement.Scope.OrganizationID, replacement.SubjectID, ownerRole).Scan(&otherOwners); err != nil {
|
||||
@@ -265,13 +285,15 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
|
||||
AND revoked_at IS NULL`, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Scope.OrganizationID, replacement.SubjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at)
|
||||
SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, replacement.ID, replacement.Scope.OrganizationID, replacement.SubjectID, replacement.Role, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return errors.New("authsqlite: replacement role has not been seeded")
|
||||
for _, value := range replacements {
|
||||
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at)
|
||||
SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, value.ID, value.Scope.OrganizationID, value.SubjectID, value.Role, value.GrantedBy, value.GrantedAt.Unix(), value.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return errors.New("authsqlite: replacement role has not been seeded")
|
||||
}
|
||||
}
|
||||
if err = appendAccessAudit(ctx, tx, audit); err != nil {
|
||||
return err
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+190
-30
@@ -7,6 +7,8 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/organizations"
|
||||
@@ -123,10 +125,28 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) CreateInvitationWithRoles(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
|
||||
return store.CreateInvitation(ctx, invitation, ownerRole, audit)
|
||||
}
|
||||
|
||||
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
|
||||
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || ownerRole != "" && !safeName(ownerRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
|
||||
return errors.New("authsqlite: invalid invitation")
|
||||
}
|
||||
roles, err := invitation.RoleNames()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if invitation.RequiredOwnerRole != "" && invitation.RequiredOwnerRole != ownerRole || audit.ActorUserID != invitation.InvitedByUserID || audit.Action != "invitation.create" || audit.ResourceType != "invitation" || audit.ResourceID != invitation.ID {
|
||||
return errors.New("authsqlite: invalid invitation authority")
|
||||
}
|
||||
if ownerRole != "" && slices.Contains(roles, ownerRole) {
|
||||
invitation.RequiredOwnerRole = ownerRole
|
||||
}
|
||||
rolesJSON, err := json.Marshal(invitation.DirectRoles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
teamIDs, err := json.Marshal(invitation.TeamIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -139,8 +159,8 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio
|
||||
if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if ownerRole != "" && invitation.DirectRole == ownerRole {
|
||||
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, ownerRole)
|
||||
if invitation.RequiredOwnerRole != "" {
|
||||
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, invitation.RequiredOwnerRole)
|
||||
if ownerErr != nil {
|
||||
return ownerErr
|
||||
}
|
||||
@@ -151,9 +171,18 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio
|
||||
if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at,id,direct_role,team_ids_json)
|
||||
SELECT ?,?,?,?,?,?,?,?,? FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id
|
||||
WHERE m.organization_id=? AND m.user_id=? AND m.status='active' AND o.status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.ID, invitation.DirectRole, teamIDs, invitation.OrganizationID, invitation.InvitedByUserID)
|
||||
for _, role := range roles {
|
||||
var count int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_roles WHERE name=?`, role).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return errors.New("authsqlite: invitation role has not been seeded")
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at,id,direct_role,team_ids_json,direct_roles_json,required_owner_role)
|
||||
SELECT ?,?,?,?,?,?,?,?,?,?,? FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id
|
||||
WHERE m.organization_id=? AND m.user_id=? AND m.status='active' AND o.status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.ID, invitation.DirectRole, teamIDs, rolesJSON, invitation.RequiredOwnerRole, invitation.OrganizationID, invitation.InvitedByUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -172,8 +201,8 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now
|
||||
}
|
||||
var invitation organizations.Invitation
|
||||
var created, expires int64
|
||||
var teamIDs []byte
|
||||
err := store.db.QueryRowContext(ctx, `SELECT id,organization_id,email_normalized,invited_by_user_id,direct_role,team_ids_json,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.ID, &invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &invitation.DirectRole, &teamIDs, &created, &expires)
|
||||
var teamIDs, rolesJSON []byte
|
||||
err := store.db.QueryRowContext(ctx, `SELECT id,organization_id,email_normalized,invited_by_user_id,direct_role,team_ids_json,direct_roles_json,required_owner_role,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.ID, &invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &invitation.DirectRole, &teamIDs, &rolesJSON, &invitation.RequiredOwnerRole, &created, &expires)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return organizations.Invitation{}, organizations.ErrInvitationNotFound
|
||||
}
|
||||
@@ -184,13 +213,20 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now
|
||||
if err = json.Unmarshal(teamIDs, &invitation.TeamIDs); err != nil || !validInvitationTeamIDs(invitation.TeamIDs) {
|
||||
return organizations.Invitation{}, organizations.ErrInvitationNotFound
|
||||
}
|
||||
if !decodeInvitationRoles(&invitation, rolesJSON) {
|
||||
return organizations.Invitation{}, organizations.ErrInvitationNotFound
|
||||
}
|
||||
invitation.CreatedAt = time.Unix(created, 0).UTC()
|
||||
invitation.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time, audit organizations.AuditEvent) error {
|
||||
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) {
|
||||
return store.AcceptInvitationWithRoles(ctx, digest, userID, "", acceptedAt, audit)
|
||||
}
|
||||
|
||||
func (store *Store) AcceptInvitationWithRoles(ctx context.Context, digest [32]byte, userID, ownerRole string, acceptedAt time.Time, audit organizations.AuditEvent) error {
|
||||
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) || audit.ActorUserID != userID || ownerRole != "" && !safeName(ownerRole) {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
@@ -198,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
|
||||
@@ -222,11 +287,12 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
|
||||
return err
|
||||
}
|
||||
}
|
||||
if directRole != "" {
|
||||
if !safeName(directRole) {
|
||||
return organizations.ErrInvitationNotFound
|
||||
for i, role := range roles {
|
||||
bindingID := "invite-" + invitationID
|
||||
if len(invitation.DirectRoles) > 0 {
|
||||
bindingID += "-" + strconv.Itoa(i)
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, "invite-"+invitationID, organizationID, userID, directRole, invitedBy, acceptedAt.Unix(), directRole)
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, bindingID, organizationID, userID, role, invitedBy, acceptedAt.Unix(), role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -241,9 +307,6 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
if organizationID != audit.OrganizationID {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -345,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")
|
||||
}
|
||||
@@ -353,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
|
||||
@@ -472,6 +551,14 @@ func (store *Store) SetMembershipStatus(ctx context.Context, organizationID, use
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -480,7 +567,12 @@ func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizati
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID); err != nil {
|
||||
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)
|
||||
@@ -534,6 +626,9 @@ func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID
|
||||
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
|
||||
}
|
||||
@@ -554,6 +649,14 @@ func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -562,7 +665,12 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID); err != nil {
|
||||
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)
|
||||
@@ -578,6 +686,9 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz
|
||||
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
|
||||
}
|
||||
@@ -597,13 +708,22 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz
|
||||
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')`, organizationID, actorUserID, organizationID, actorUserID)
|
||||
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)`, organizationID, actorUserID, organizationID, actorUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -613,6 +733,27 @@ func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID,
|
||||
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 {
|
||||
@@ -665,7 +806,7 @@ func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, o
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
|
||||
FROM gwf_access_bindings b
|
||||
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
|
||||
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
|
||||
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
|
||||
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
|
||||
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
|
||||
AND b.revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&otherActiveOwners); err != nil {
|
||||
@@ -696,7 +837,7 @@ func (store *Store) Invitations(ctx context.Context, organizationID string, limi
|
||||
if !opaqueID(organizationID) || limit < 1 || limit > 1000 {
|
||||
return nil, errors.New("authsqlite: invalid invitation query")
|
||||
}
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT id,email_normalized,invited_by_user_id,direct_role,team_ids_json,created_at,expires_at,COALESCE(used_at,0),COALESCE(revoked_at,0) FROM gwf_organization_invitations WHERE organization_id=? ORDER BY created_at DESC LIMIT ?`, organizationID, limit)
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT id,email_normalized,invited_by_user_id,direct_role,team_ids_json,direct_roles_json,required_owner_role,created_at,expires_at,COALESCE(used_at,0),COALESCE(revoked_at,0) FROM gwf_organization_invitations WHERE organization_id=? ORDER BY created_at DESC,id LIMIT ?`, organizationID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -705,13 +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 {
|
||||
@@ -742,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
|
||||
@@ -767,15 +919,23 @@ func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invita
|
||||
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
var directRole string
|
||||
if err = tx.QueryRowContext(ctx, `SELECT direct_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&directRole); err != nil {
|
||||
var invitation organizations.Invitation
|
||||
var rolesJSON []byte
|
||||
if err = tx.QueryRowContext(ctx, `SELECT direct_role,direct_roles_json,required_owner_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&invitation.DirectRole, &rolesJSON, &invitation.RequiredOwnerRole); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ownerRole != "" && directRole == ownerRole {
|
||||
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, ownerRole)
|
||||
if !decodeInvitationRoles(&invitation, rolesJSON) {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
roles, _ := invitation.RoleNames()
|
||||
if invitation.RequiredOwnerRole == "" && ownerRole != "" && slices.Contains(roles, ownerRole) {
|
||||
invitation.RequiredOwnerRole = ownerRole
|
||||
}
|
||||
if invitation.RequiredOwnerRole != "" {
|
||||
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, invitation.RequiredOwnerRole)
|
||||
if ownerErr != nil {
|
||||
return ownerErr
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
|
||||
return store, nil
|
||||
}
|
||||
|
||||
const SchemaVersion = 9
|
||||
const SchemaVersion = 11
|
||||
|
||||
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
|
||||
var exists int
|
||||
@@ -179,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`},
|
||||
@@ -189,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 {
|
||||
@@ -239,6 +242,12 @@ func (store *Store) Migrate(ctx context.Context) error {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(9,?)`, time.Now().UTC().Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(10,?)`, time.Now().UTC().Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -375,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)
|
||||
@@ -407,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 {
|
||||
|
||||
+120
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
+69
-2
@@ -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
|
||||
@@ -85,3 +144,11 @@ application concern belongs in the shared module.
|
||||
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.
|
||||
|
||||
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
|
||||
module checksum:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.20
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.28
|
||||
go mod verify
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
|
||||
and request the containing module at an exact version:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.20
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.25
|
||||
```
|
||||
|
||||
Only imported packages are compiled and linked. The packages nevertheless
|
||||
|
||||
+124
-7
@@ -8,17 +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.
|
||||
When `OwnerRole` is configured, creating or revoking an invitation carrying
|
||||
that role additionally requires a current active direct owner inside the same
|
||||
SQLite transaction. A broad access-management permission may administer
|
||||
ordinary invitations but cannot create or cancel owner access.
|
||||
roles, teams, consumption marker, and audit event in one transaction. The
|
||||
recipient and issuing member must remain active, fully registered users of an
|
||||
active organization; a suspended recipient cannot use an invitation as implicit
|
||||
reactivation. 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;
|
||||
@@ -47,6 +133,37 @@ will not demote the final active direct owner. The application must still
|
||||
authorize the administrator and bind any required fresh passkey assertion to
|
||||
the organization, target user, target role, and expected IDs.
|
||||
|
||||
For combinations such as Buyer plus Billing Manager, use
|
||||
`access.ReplaceOrganizationUserRoles` with a non-empty, unique `Roles` array
|
||||
(maximum sixteen) and the same `ExpectedBindingIDs` convention. This operation
|
||||
requires a current direct owner inside the write transaction for every change;
|
||||
the older single-role API retains its delegated non-owner administration policy.
|
||||
The replacement is all-or-nothing, leaves narrower grants untouched, and records
|
||||
one audit. `ErrRoleChangeConflict` means refresh the displayed bindings, not retry
|
||||
the old request silently. `RoleSetRepository` is required; separate grant/revoke
|
||||
calls are not a fallback. A basic-member role with no permissions can represent
|
||||
membership without purchasing or billing access.
|
||||
|
||||
Role names and capabilities remain application policy. In particular, customer
|
||||
roles must not be replaceable with merchant roles merely because both policies
|
||||
use the same database. Routine customer changes do not inherently require a
|
||||
passkey ceremony; the application decides when an action needs fresh proof.
|
||||
|
||||
## Schema 10 compatibility
|
||||
|
||||
Schema 10 adds `direct_roles_json` and `required_owner_role` to stored invitations.
|
||||
The explicit migration preserves legacy `direct_role`, hashes, dates, teams, and
|
||||
consumption state. It does not guess which application role historically meant
|
||||
owner. Configure the correct `OwnerRole` when accepting pre-schema-10 owner
|
||||
invitations; that service policy supplies their acceptance-time owner check.
|
||||
New invitations carry the persisted requirement themselves.
|
||||
|
||||
Use `OpenWithOptions(..., OpenOptions{Migrate: false})` plus
|
||||
`RequireCurrentSchema` at application startup and an explicit operator migration
|
||||
command. Retain a verified backup before migrating. Schema-9 binaries reject
|
||||
schema 10 when using the startup check and are not approved writers after the
|
||||
upgrade; a binary rollback must not overwrite newer accepted data.
|
||||
|
||||
`access.Service` evaluates a permission against a complete resource scope:
|
||||
|
||||
```go
|
||||
|
||||
@@ -19,6 +19,10 @@ authorization decisions, session cookie, HTML, and local recovery command.
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+115
-30
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -28,6 +29,7 @@ var (
|
||||
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}$`)
|
||||
)
|
||||
@@ -76,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
|
||||
}
|
||||
@@ -128,13 +135,17 @@ type Options struct {
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
OwnerRole string
|
||||
// OwnerManagedInvitations requires a current direct owner to create, revoke,
|
||||
// and remain the grantor of an invitation until it is accepted.
|
||||
OwnerManagedInvitations bool
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
ownerRole string
|
||||
repository Repository
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
ownerRole string
|
||||
ownerManagedInvitations bool
|
||||
}
|
||||
|
||||
func New(repository Repository, options Options) (*Service, error) {
|
||||
@@ -147,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
|
||||
}
|
||||
@@ -181,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 {
|
||||
@@ -289,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
|
||||
}
|
||||
@@ -297,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
|
||||
@@ -310,11 +340,22 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
|
||||
}
|
||||
now := service.now().UTC()
|
||||
invitation := Invitation{ID: id, Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, DirectRole: input.DirectRole, TeamIDs: append([]string(nil), input.TeamIDs...), CreatedAt: now, ExpiresAt: now.Add(lifetime)}
|
||||
audit, err := service.audit(invitedBy, organizationID, "invitation.create", "invitation", id, "Organization invitation created")
|
||||
if len(input.DirectRoles) > 0 {
|
||||
invitation.DirectRoles = roles
|
||||
}
|
||||
if service.ownerManagedInvitations || service.ownerRole != "" && slices.Contains(roles, service.ownerRole) {
|
||||
invitation.RequiredOwnerRole = service.ownerRole
|
||||
}
|
||||
audit, err := service.auditWithRequest(invitedBy, organizationID, "invitation.create", "invitation", id, input.RequestID, "Organization invitation created")
|
||||
if err != nil {
|
||||
return "", Invitation{}, err
|
||||
}
|
||||
if err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit); err != nil {
|
||||
if roleSupport {
|
||||
err = roleRepository.CreateInvitationWithRoles(ctx, invitation, service.ownerRole, audit)
|
||||
} else {
|
||||
err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit)
|
||||
}
|
||||
if err != nil {
|
||||
return "", Invitation{}, err
|
||||
}
|
||||
return raw, invitation, nil
|
||||
@@ -334,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)
|
||||
}
|
||||
|
||||
@@ -367,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")
|
||||
@@ -375,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
|
||||
@@ -484,6 +547,10 @@ type MembershipStatusChange struct {
|
||||
}
|
||||
|
||||
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 ||
|
||||
@@ -493,14 +560,21 @@ func (service *Service) ChangeMembershipStatus(ctx context.Context, input Member
|
||||
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 !ok {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -525,6 +599,10 @@ type MembershipRemoval struct {
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -532,14 +610,21 @@ func (service *Service) RemoveMembershipIfCurrent(ctx context.Context, input Mem
|
||||
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 !ok {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
.gitea/workflows/verify.yml
|
||||
.gitignore
|
||||
CHANGELOG.md
|
||||
AI_DISCLOSURE.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
LICENSES.md
|
||||
@@ -20,12 +21,17 @@ 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
|
||||
@@ -36,8 +42,17 @@ 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
|
||||
@@ -45,16 +60,35 @@ 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
|
||||
@@ -62,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
|
||||
@@ -83,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user