Compare commits

...
Author SHA1 Message Date
gamertan d991ad4bdb Record CMS package verification for preview 27
verify / verify (push) Successful in 4m39s
2026-09-10 12:39:30 -04:00
gamertan 57d74bf601 Add revision-aware CMS taxonomies and relationships 2026-09-10 12:37:21 -04:00
gamertan a16283efd7 Add session-bound personal profile editing
verify / verify (push) Successful in 4m35s
2026-09-05 02:37:38 -04:00
gamertan 7c68a3499a Add bounded administrative identity directories
verify / verify (push) Successful in 4m31s
2026-09-05 01:19:57 -04:00
gamertan d476179148 Guard customer-owned profile and membership transactions
verify / verify (push) Successful in 4m25s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-09-05 00:33:24 -04:00
gamertan f142ac23a9 Invalidate old invitations when organization membership changes
verify / verify (push) Successful in 4m15s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-09-05 00:12:40 -04:00
gamertan c0986168bc Support atomic organization role sets and owner-managed invitations
verify / verify (push) Successful in 4m17s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-09-05 00:10:08 -04:00
gamertan ed0cc8ceff Add atomic owned organization creation
verify / verify (push) Successful in 3m48s
2026-09-04 23:19:05 -04:00
gamertan b1710e08b8 Verify passkey algorithms from COSE keys
verify / verify (push) Successful in 3m38s
2026-09-04 12:19:15 -04:00
46 changed files with 4004 additions and 91 deletions
+10
View File
@@ -0,0 +1,10 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# AI assistance
Codex has materially assisted with implementation, tests, documentation, and
integration work in this project, including the organization and access-control
extensions. Assistance is disclosed here rather than repeated in every commit
subject. Repository tests, review, and release evidence—not the use of a
particular tool—determine readiness. Automated checks do not imply that every
line has received independent human review.
+101
View File
@@ -2,6 +2,107 @@
# Changelog # Changelog
## 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 ## v0.1.0-preview.20 — 2026-09-04
- Extend the direct-owner transaction boundary to invitations. Creating or - Extend the direct-owner transaction boundary to invitations. Creating or
+4 -3
View File
@@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and
deployment. Adopt one boundary at a time; Go compiles and links only the deployment. Adopt one boundary at a time; Go compiles and links only the
packages you import. packages you import.
> **Public preview:** `v0.1.0-preview.20`. APIs may change before a stable > **Public preview:** `v0.1.0-preview.27`. APIs may change before a stable
> release. Linux is the maintained release platform. > release. Linux is the maintained release platform.
## Why Web Foundations? ## Why Web Foundations?
@@ -44,6 +44,7 @@ packages you import.
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) | | Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
| Private SQLite persistence | [`authsqlite`](authsqlite) | | Private SQLite persistence | [`authsqlite`](authsqlite) |
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) | | 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) | | Organizations, teams, and invitations | [`organizations`](organizations) |
| Organization-scoped roles and temporary access | [`access`](access) | | Organization-scoped roles and temporary access | [`access`](access) |
| Application-classified request abuse | [`abuse`](abuse) | | Application-classified request abuse | [`abuse`](abuse) |
@@ -57,14 +58,14 @@ owns—and, just as importantly, what remains application policy.
Pin the preview in an application module: Pin the preview in an application module:
```bash ```bash
go get gamertan.com/web@v0.1.0-preview.20 go get gamertan.com/web@v0.1.0-preview.27
go mod verify go mod verify
``` ```
An application may name the first package it intends to adopt: An application may name the first package it intends to adopt:
```bash ```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.20 go get gamertan.com/web/requestmeta@v0.1.0-preview.27
``` ```
The version belongs to the `gamertan.com/web` module. See the The version belongs to the `gamertan.com/web` module. See the
+34
View File
@@ -0,0 +1,34 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Current work
## CMS classification and relationships
Implement `cms` and `cmssqlite` primitives for Gamertan's existing typed content:
named flat taxonomies, stable terms with rename history, immutable revision
associations, explicit publication and bidirectional related-content queries.
Keep authorization, audits, application schemas, templates and commerce policy
with their callers. Writes join caller-owned SQLite transactions.
Implemented: validated values, explicit schema, caller-owned transactions,
immutable associations, publication pointers, term aliases and public readers.
Verified: focused race tests for scope isolation, conflicts, draft/public
transitions, retirement, rename collisions, pagination and rollback. Gamertan's
consumer HTTP regressions now exercise real content/catalog/case-study routing,
draft isolation, restore, role/CSRF checks and unchanged catalog data.
Consolidated evidence: the full package race suite passed; association fuzzing
passed 698,275 executions. Dependency/module and license checks passed. Vet has
only the existing vendored COSE diagnostic documented by `scripts/verify.sh`.
The consumer's full Go suite and native trusted-TLS editor checks passed.
The consolidated `scripts/verify.sh` passed, including all 126 allowlisted
public-export paths, exported-package compilation, full tests/race, the existing
vet diagnostic exception, dependency/license checks and the starter build.
Release candidate: `v0.1.0-preview.27`. Next: publish these verified bytes and
pin the consumer immutably. No unrelated auth changes in this slice.
Status: implemented and consolidated verification/export complete. Consumer
deployment remains separately recorded in its repository. Release history stays
in CHANGELOG; publication is confirmed by the remote tag, not a checkbox here.
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MPL-2.0
package access
import (
"context"
"errors"
"sort"
)
var ErrRoleSetUnsupported = errors.New("access: atomic role sets are unsupported")
// RoleSetRepository commits every replacement and the audit atomically. There
// is no sequence of individual Grant/Revoke calls as a fallback.
type RoleSetRepository interface {
ReplaceOrganizationUserRoles(context.Context, []string, []Binding, string, AuditEvent) error
}
type OrganizationUserRolesChange struct {
OrganizationID, UserID, ActorUserID, RequestID string
Roles, ExpectedBindingIDs []string
}
// ReplaceOrganizationUserRoles replaces the direct organization-wide role set
// for one active member. Team and narrower grants are unaffected. This bulk
// operation requires a current direct owner inside the write transaction;
// applications still authorize their customer/merchant and allowed-role boundary.
func (service *Service) ReplaceOrganizationUserRoles(ctx context.Context, input OrganizationUserRolesChange) ([]Binding, error) {
repository, ok := service.repository.(RoleSetRepository)
if !ok {
return nil, ErrRoleSetUnsupported
}
if service.ownerRole == "" || !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || !text(input.RequestID, 128, true) || len(input.Roles) < 1 || len(input.Roles) > 16 {
return nil, errors.New("access: invalid organization role set")
}
roles := append([]string(nil), input.Roles...)
sort.Strings(roles)
for i, role := range roles {
if _, exists := service.policy.Roles[role]; !exists || i > 0 && roles[i-1] == role {
return nil, errors.New("access: unknown or duplicate role")
}
}
expected, err := canonicalBindingIDs(input.ExpectedBindingIDs)
if err != nil {
return nil, err
}
now := service.now().UTC()
bindings := make([]Binding, 0, len(roles))
for _, role := range roles {
id, err := randomID(service.random)
if err != nil {
return nil, err
}
bindings = append(bindings, Binding{ID: id, SubjectKind: User, SubjectID: input.UserID, Role: role, Scope: Scope{OrganizationID: input.OrganizationID}, GrantedBy: input.ActorUserID, GrantedAt: now})
}
id, err := randomID(service.random)
if err != nil {
return nil, err
}
audit := AuditEvent{ID: id, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.role.replace", ResourceType: "user", ResourceID: input.UserID, RequestID: input.RequestID, Summary: "Direct organization roles replaced", CreatedAt: now}
if err := repository.ReplaceOrganizationUserRoles(ctx, expected, bindings, service.ownerRole, audit); err != nil {
return nil, err
}
return bindings, nil
}
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MPL-2.0
package access
import (
"context"
"errors"
"slices"
"strings"
"testing"
"time"
)
type roleSetRepositoryStub struct {
repositoryStub
calls int
expected []string
roles []Binding
audit AuditEvent
}
func (r *roleSetRepositoryStub) ReplaceOrganizationUserRoles(_ context.Context, expected []string, bindings []Binding, _ string, audit AuditEvent) error {
r.calls++
r.expected, r.roles, r.audit = expected, bindings, audit
return nil
}
func TestRoleSetServiceBoundsAndCanonicalCopies(t *testing.T) {
policy := Policy{Roles: map[string]string{"owner": "Owner", "buyer": "Buyer", "billing": "Billing"}, Permissions: map[string]string{"purchase": "Purchase"}, Grants: map[string][]string{"owner": {"purchase"}, "buyer": {"purchase"}, "billing": {}}}
r := &roleSetRepositoryStub{}
service, err := New(r, policy, Options{OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
input := OrganizationUserRolesChange{OrganizationID: "organization-123", UserID: "member-12345678", ActorUserID: "owner-12345678", Roles: []string{"buyer", "billing"}, ExpectedBindingIDs: []string{"binding-second", "binding-first"}, RequestID: "request-roles"}
bindings, err := service.ReplaceOrganizationUserRoles(t.Context(), input)
if err != nil {
t.Fatal(err)
}
if r.calls != 1 || len(bindings) != 2 || bindings[0].Role != "billing" || bindings[1].Role != "buyer" || bindings[0].ID == bindings[1].ID || !slices.Equal(r.expected, []string{"binding-first", "binding-second"}) {
t.Fatalf("bindings=%v expected=%v calls=%d", bindings, r.expected, r.calls)
}
if !slices.Equal(input.Roles, []string{"buyer", "billing"}) || !slices.Equal(input.ExpectedBindingIDs, []string{"binding-second", "binding-first"}) {
t.Fatal("caller input was sorted in place")
}
if r.audit.RequestID != input.RequestID || r.audit.ActorUserID != input.ActorUserID || r.audit.ResourceID != input.UserID {
t.Fatalf("audit=%+v", r.audit)
}
for _, roles := range [][]string{nil, {"buyer", "buyer"}, {"missing"}, make([]string, 17)} {
invalid := input
invalid.Roles = roles
if _, err = service.ReplaceOrganizationUserRoles(t.Context(), invalid); err == nil {
t.Fatalf("invalid roles=%v", roles)
}
}
for _, expected := range [][]string{{"bad"}, {"binding-first", "binding-first"}, make([]string, 17)} {
invalid := input
invalid.ExpectedBindingIDs = expected
if _, err = service.ReplaceOrganizationUserRoles(t.Context(), invalid); err == nil {
t.Fatalf("invalid IDs=%v", expected)
}
}
if r.calls != 1 {
t.Fatal("invalid input reached repository")
}
legacy, err := New(&repositoryStub{}, policy, Options{OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
if _, err = legacy.ReplaceOrganizationUserRoles(t.Context(), input); !errors.Is(err, ErrRoleSetUnsupported) {
t.Fatalf("fallback=%v", err)
}
broken, err := New(r, policy, Options{OwnerRole: "owner", Random: strings.NewReader("")})
if err != nil {
t.Fatal(err)
}
if _, err = broken.ReplaceOrganizationUserRoles(t.Context(), input); err == nil || r.calls != 1 {
t.Fatal("random failure reached storage")
}
withoutOwner, err := New(r, policy, Options{Now: func() time.Time { return time.Unix(2000, 0) }})
if err != nil {
t.Fatal(err)
}
if _, err = withoutOwner.ReplaceOrganizationUserRoles(t.Context(), input); err == nil || r.calls != 1 {
t.Fatal("role set without owner boundary accepted")
}
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MPL-2.0
package auth
import (
"context"
"errors"
)
var ErrDirectoryQuery = errors.New("auth: invalid directory query")
// UserDirectoryQuery requests a bounded instance-wide identity listing. Search
// is literal text, not a query language. AfterID is an exclusive stable-ID cursor;
// Limit defaults to 50 and may not exceed 200.
type UserDirectoryQuery struct {
Search, AfterID string
Limit int
}
type UserDirectoryPage struct {
Users []User
NextID string
}
// UserDirectoryRepository is an optional administrative read capability, not an
// extension of ordinary authentication. Callers MUST authorize instance-wide
// identity access before each call. Results include incomplete/inactive accounts
// but never credentials, session material, recovery codes or permission grants.
// Pagination is a current view, not a snapshot across requests.
type UserDirectoryRepository interface {
UserDirectory(context.Context, UserDirectoryQuery) (UserDirectoryPage, error)
}
+75
View File
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: MPL-2.0
package auth
import (
"context"
"errors"
"strings"
"time"
"unicode"
"unicode/utf8"
)
var (
ErrProfileInput = errors.New("auth: invalid profile change")
ErrProfileAccess = errors.New("auth: profile session is unavailable")
ErrProfileConflict = errors.New("auth: profile changed; reload before editing")
ErrUsernameUnavailable = errors.New("auth: username is unavailable")
)
// OwnProfile contains mutable identity, not credentials or organization roles.
// Revision is independent of timestamps and increases for every profile edit.
type OwnProfile struct {
UserID, Username, Email, DisplayName string
Revision int64
}
// ProfileEdit is a trusted repository command, not an HTTP input model. The
// application must authenticate the session, validate CSRF/origin and rate-limit
// mutations. Username edits additionally require recent reauthentication (and
// any account-specific MFA). For password reauthentication, supply the verified
// hash so a concurrent password reset invalidates the write. After verified
// passkey approval, leave it empty. Do not log or serialize this command.
type ProfileEdit struct {
UserID string
SessionDigest [32]byte
ExpectedRevision int64
Field, Value string
ExpectedPasswordHash string
}
// NormalizeProfileValue validates only supported fields. Email is deliberately
// absent: verified mailbox changes need a separate pending/confirmation flow.
func NormalizeProfileValue(field, value string) (string, error) {
value = strings.TrimSpace(value)
switch field {
case "username":
if !identifierPattern.MatchString(value) {
return "", ErrProfileInput
}
case "display_name":
if value == "" || len(value) > 128 || !utf8.ValidString(value) {
return "", ErrProfileInput
}
for _, r := range value {
if unicode.IsControl(r) {
return "", ErrProfileInput
}
}
default:
return "", ErrProfileInput
}
return value, nil
}
// OwnProfileRepository is optional; no change to the authentication Repository
// interface is required. It derives access from the current session, never from
// a site-wide administrator flag. Implementations atomically recheck identity,
// session and revision, mutate one field, and append the audit. Username edits
// revoke other sessions but preserve the acting session. They never reassign
// stable IDs, memberships, passkeys, billing identities or historical records.
type OwnProfileRepository interface {
OwnProfile(context.Context, [32]byte, time.Time) (OwnProfile, error)
UpdateOwnProfile(context.Context, ProfileEdit, AuditEvent) (OwnProfile, error)
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: MPL-2.0
package auth
import "testing"
func TestNormalizeProfileValue(t *testing.T) {
for _, value := range []struct{ field, value, want string }{
{"username", " Reader.One ", "Reader.One"}, {"display_name", " Émilie ★ ", "Émilie ★"},
} {
got, err := NormalizeProfileValue(value.field, value.value)
if err != nil || got != value.want {
t.Fatalf("normalization: %q %v", got, err)
}
}
for _, value := range []struct{ field, value string }{
{"email", "new@example.test"}, {"role", "owner"}, {"username", "a"}, {"username", "foo@bar"},
{"display_name", ""}, {"display_name", "hello\x00world"}, {"display_name", "hello\nworld"}, {"display_name", string([]byte{0xff})},
} {
if _, err := NormalizeProfileValue(value.field, value.value); err == nil {
t.Fatalf("invalid field accepted: %s", value.field)
}
}
}
func FuzzProfileValue(f *testing.F) {
f.Add("username", "reader.one")
f.Add("display_name", "Émilie")
f.Add("email", "a@example.test")
f.Fuzz(func(t *testing.T, field, value string) {
normal, err := NormalizeProfileValue(field, value)
if err != nil {
return
}
if len(normal) == 0 || len(normal) > 128 {
t.Fatal("unbounded value")
}
again, err := NormalizeProfileValue(field, normal)
if err != nil || again != normal {
t.Fatal("unstable normalization")
}
})
}
+37 -15
View File
@@ -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 { 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 store.replaceOrganizationUserRoles(ctx, expected, []access.Binding{replacement}, ownerRole, audit, false)
return errors.New("authsqlite: invalid organization role replacement") }
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) tx, err := store.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return err 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 result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
WHERE organization_id=? AND user_id=? AND status='active' 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_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 { if err != nil {
return err return err
} }
@@ -195,7 +214,7 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) if err = tx.QueryRowContext(ctx, `SELECT COUNT(*)
FROM gwf_organization_memberships m FROM gwf_organization_memberships m
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active' 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 { WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil {
return err return err
} }
@@ -231,10 +250,11 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
if !slices.Equal(currentIDs, expected) { if !slices.Equal(currentIDs, expected) {
return access.ErrRoleChangeConflict return access.ErrRoleChangeConflict
} }
if len(currentRoles) == 1 && currentRoles[0] == replacement.Role { slices.Sort(currentRoles)
if slices.Equal(currentRoles, roles) {
return access.ErrRoleUnchanged 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) actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, replacement.Scope.OrganizationID, replacement.GrantedBy, ownerRole)
if ownerErr != nil { if ownerErr != nil {
return ownerErr return ownerErr
@@ -243,12 +263,12 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
return access.ErrOwnerAuthority return access.ErrOwnerAuthority
} }
} }
if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) { if !slices.Contains(roles, ownerRole) && slices.Contains(currentRoles, ownerRole) {
var otherOwners int var otherOwners int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id) if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
FROM gwf_access_bindings b 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_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=? 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.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 { 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 { AND revoked_at IS NULL`, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Scope.OrganizationID, replacement.SubjectID); err != nil {
return err 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) for _, value := range replacements {
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) 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)
if err != nil { 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)
return err if err != nil {
} return err
if changed, _ := result.RowsAffected(); changed != 1 { }
return errors.New("authsqlite: replacement role has not been seeded") if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: replacement role has not been seeded")
}
} }
if err = appendAccessAudit(ctx, tx, audit); err != nil { if err = appendAccessAudit(ctx, tx, audit); err != nil {
return err return err
+95
View File
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"strings"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/organizations"
)
var _ auth.UserDirectoryRepository = (*Store)(nil)
var _ organizations.DirectoryRepository = (*Store)(nil)
// UserDirectory is an administrative read; the adapter cannot infer application
// authorization. Search covers ID, username, email and display name. SQLite LIKE
// folds ASCII case; non-ASCII display-name text matches with its original case.
func (store *Store) UserDirectory(ctx context.Context, query auth.UserDirectoryQuery) (auth.UserDirectoryPage, error) {
pattern, limit, valid := directoryQuery(query.Search, query.AfterID, query.Limit)
if !valid {
return auth.UserDirectoryPage{}, auth.ErrDirectoryQuery
}
rows, err := store.db.QueryContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at
FROM gwf_users WHERE id>? AND (?='' OR id=? OR username_normalized LIKE ? ESCAPE '\' OR email_normalized LIKE ? ESCAPE '\' OR display_name LIKE ? ESCAPE '\')
ORDER BY id LIMIT ?`, query.AfterID, strings.TrimSpace(query.Search), strings.TrimSpace(query.Search), strings.ToLower(pattern), strings.ToLower(pattern), pattern, limit+1)
if err != nil {
return auth.UserDirectoryPage{}, err
}
defer rows.Close()
page := auth.UserDirectoryPage{Users: make([]auth.User, 0, limit)}
for rows.Next() {
user, err := scanPasskeyUser(rows)
if err != nil {
return auth.UserDirectoryPage{}, err
}
page.Users = append(page.Users, user)
}
if err = rows.Err(); err != nil {
return auth.UserDirectoryPage{}, err
}
if len(page.Users) > limit {
page.Users = page.Users[:limit]
page.NextID = page.Users[limit-1].ID
}
return page, nil
}
// OrganizationDirectory reads all personal/business and active/archived records.
// It does not join membership, grant access, or choose a merchant. Search covers
// exact ID and literal slug/name text using SQLite's ASCII case folding.
func (store *Store) OrganizationDirectory(ctx context.Context, query organizations.DirectoryQuery) (organizations.DirectoryPage, error) {
pattern, limit, valid := directoryQuery(query.Search, query.AfterID, query.Limit)
if !valid {
return organizations.DirectoryPage{}, organizations.ErrDirectoryQuery
}
rows, err := store.db.QueryContext(ctx, `SELECT id,slug,name,status,personal,revision,created_at,updated_at
FROM gwf_organizations WHERE id>? AND (?='' OR id=? OR slug LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\')
ORDER BY id LIMIT ?`, query.AfterID, strings.TrimSpace(query.Search), strings.TrimSpace(query.Search), pattern, pattern, limit+1)
if err != nil {
return organizations.DirectoryPage{}, err
}
defer rows.Close()
page := organizations.DirectoryPage{Organizations: make([]organizations.Organization, 0, limit)}
for rows.Next() {
var value organizations.Organization
var created, updated int64
if err = rows.Scan(&value.ID, &value.Slug, &value.Name, &value.Status, &value.Personal, &value.Revision, &created, &updated); err != nil {
return organizations.DirectoryPage{}, err
}
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
page.Organizations = append(page.Organizations, value)
}
if err = rows.Err(); err != nil {
return organizations.DirectoryPage{}, err
}
if len(page.Organizations) > limit {
page.Organizations = page.Organizations[:limit]
page.NextID = page.Organizations[limit-1].ID
}
return page, nil
}
func directoryQuery(search, after string, limit int) (string, int, bool) {
if !text(search, 128, true) || after != "" && !opaqueID(after) || limit < 0 || limit > 200 {
return "", 0, false
}
if limit == 0 {
limit = 50
}
// Wildcards and the escape character are literal user text, never operators.
pattern := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.TrimSpace(search)) + "%"
return pattern, limit, true
}
+146
View File
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"gamertan.com/web/auth"
"gamertan.com/web/organizations"
)
func TestInstanceDirectoriesAreBoundedCredentialFreeAndIndependentOfMembership(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "directory.sqlite"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
ctx := t.Context()
for index := 0; index < 205; index++ {
id := fmt.Sprintf("record-%03d", index)
status := []string{"active", "suspended", "disabled"}[index%3]
_, err = store.db.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,1,1)`, id, id, id, id+"@example.test", id+"@example.test", "Person "+id, status, index%2, index%5 == 0)
if err != nil {
t.Fatal(err)
}
_, err = store.db.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,status,revision,created_at,updated_at) VALUES(?,?,?,?,?,1,1,1)`, id, id, "Business "+id, index%2, []string{"active", "archived"}[index%2])
if err != nil {
t.Fatal(err)
}
}
users, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{})
if err != nil || len(users.Users) != 50 || users.NextID != "record-049" {
t.Fatalf("default users: %d %q %v", len(users.Users), users.NextID, err)
}
if !users.Users[0].RegistrationPending || users.Users[1].Status != "suspended" || !users.Users[1].PasswordChangeRequired || users.Users[2].Status != "disabled" {
t.Fatal("administrative account states were hidden")
}
encoded, _ := json.Marshal(users)
for _, secret := range []string{"password_hash", "Session", "Digest", "Credential", "Recovery"} {
if strings.Contains(string(encoded), secret) {
t.Fatalf("directory leaked credential field %s", secret)
}
}
for _, size := range []int{1, 50, 200} {
userAfter, orgAfter, count := "", "", 0
for {
users, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{AfterID: userAfter, Limit: size})
if err != nil {
t.Fatal(err)
}
orgs, err := store.OrganizationDirectory(ctx, organizations.DirectoryQuery{AfterID: orgAfter, Limit: size})
if err != nil {
t.Fatal(err)
}
if len(users.Users) != len(orgs.Organizations) || len(users.Users) > size {
t.Fatal("invalid page bound")
}
for index, user := range users.Users {
want := fmt.Sprintf("record-%03d", count)
if user.ID != want || orgs.Organizations[index].ID != want {
t.Fatalf("pagination skipped/duplicated %s", want)
}
count++
}
if users.NextID == "" || orgs.NextID == "" {
if users.NextID != orgs.NextID || count != 205 {
t.Fatalf("early end: %d", count)
}
break
}
userAfter, orgAfter = users.NextID, orgs.NextID
}
}
orgs, err := store.OrganizationDirectory(ctx, organizations.DirectoryQuery{Limit: 2})
if err != nil || orgs.Organizations[0].Personal || !orgs.Organizations[1].Personal || orgs.Organizations[1].Status != "archived" {
t.Fatal("personal/archived organizations omitted")
}
// A display-name change cannot move a record behind a stable-ID cursor.
if _, err = store.db.Exec(`UPDATE gwf_users SET display_name='AAA' WHERE id='record-050'`); err != nil {
t.Fatal(err)
}
next, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{AfterID: "record-049", Limit: 1})
if err != nil || next.Users[0].ID != "record-050" {
t.Fatal("name change disturbed cursor")
}
}
func TestInstanceDirectoryLiteralSearchValidationAndCancellation(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "directory.sqlite"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
_, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES('person-001','Alice','alice','Alice@example.test','alice@example.test','Élodie 50%_\ works','active',1,1)`)
if err != nil {
t.Fatal(err)
}
_, err = store.db.Exec(`INSERT INTO gwf_organizations(id,slug,name,personal,status,revision,created_at,updated_at) VALUES('company-001','alice-company','Élodie 50%_\ works',0,'active',1,1,1)`)
if err != nil {
t.Fatal(err)
}
for _, search := range []string{"", " ALICE ", "example.test", "person-001", "Élodie", `50%_\`} {
page, err := store.UserDirectory(t.Context(), auth.UserDirectoryQuery{Search: search})
if err != nil || len(page.Users) != 1 || page.NextID != "" {
t.Errorf("user literal search %q: %#v %v", search, page, err)
}
}
for _, search := range []string{"", "ALICE", "company-001", "Élodie", `50%_\`} {
page, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: search})
if err != nil || len(page.Organizations) != 1 || page.NextID != "" {
t.Errorf("organization literal search %q: %#v %v", search, page, err)
}
}
for _, search := range []string{"absent", "%' OR 1=1 --", "%_%", "\\_%"} {
users, err := store.UserDirectory(t.Context(), auth.UserDirectoryQuery{Search: search})
if err != nil || users.Users == nil || len(users.Users) != 0 {
t.Errorf("nonliteral user search %q", search)
}
orgs, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: search})
if err != nil || orgs.Organizations == nil || len(orgs.Organizations) != 0 {
t.Errorf("nonliteral org search %q", search)
}
}
for _, query := range []auth.UserDirectoryQuery{{Search: strings.Repeat("a", 129)}, {Search: "bad\x00value"}, {Search: "bad\nvalue"}, {Search: "\xff"}, {AfterID: "bad/id"}, {AfterID: strings.Repeat("a", 129)}, {Limit: -1}, {Limit: 201}} {
if _, err := store.UserDirectory(t.Context(), query); !errors.Is(err, auth.ErrDirectoryQuery) {
t.Errorf("invalid user query accepted: %#v %v", query, err)
}
if _, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: query.Search, AfterID: query.AfterID, Limit: query.Limit}); !errors.Is(err, organizations.ErrDirectoryQuery) {
t.Errorf("invalid org query accepted: %#v %v", query, err)
}
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err = store.UserDirectory(ctx, auth.UserDirectoryQuery{}); !errors.Is(err, context.Canceled) {
t.Fatalf("user cancellation: %v", err)
}
if _, err = store.OrganizationDirectory(ctx, organizations.DirectoryQuery{}); !errors.Is(err, context.Canceled) {
t.Fatalf("organization cancellation: %v", err)
}
}
+190 -30
View File
@@ -7,6 +7,8 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"slices"
"strconv"
"time" "time"
"gamertan.com/web/organizations" "gamertan.com/web/organizations"
@@ -123,10 +125,28 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or
return nil 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 { 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) { 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") 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) teamIDs, err := json.Marshal(invitation.TeamIDs)
if err != nil { if err != nil {
return err 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 { if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil {
return err return err
} }
if ownerRole != "" && invitation.DirectRole == ownerRole { if invitation.RequiredOwnerRole != "" {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, ownerRole) actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, invitation.RequiredOwnerRole)
if ownerErr != nil { if ownerErr != nil {
return ownerErr 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 { if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
return err 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) for _, role := range roles {
SELECT ?,?,?,?,?,?,?,?,? FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id var count int
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) 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 { if err != nil {
return err return err
} }
@@ -172,8 +201,8 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now
} }
var invitation organizations.Invitation var invitation organizations.Invitation
var created, expires int64 var created, expires int64
var teamIDs []byte var teamIDs, rolesJSON []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) 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) { if errors.Is(err, sql.ErrNoRows) {
return organizations.Invitation{}, organizations.ErrInvitationNotFound 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) { if err = json.Unmarshal(teamIDs, &invitation.TeamIDs); err != nil || !validInvitationTeamIDs(invitation.TeamIDs) {
return organizations.Invitation{}, organizations.ErrInvitationNotFound return organizations.Invitation{}, organizations.ErrInvitationNotFound
} }
if !decodeInvitationRoles(&invitation, rolesJSON) {
return organizations.Invitation{}, organizations.ErrInvitationNotFound
}
invitation.CreatedAt = time.Unix(created, 0).UTC() invitation.CreatedAt = time.Unix(created, 0).UTC()
invitation.ExpiresAt = time.Unix(expires, 0).UTC() invitation.ExpiresAt = time.Unix(expires, 0).UTC()
return invitation, nil return invitation, nil
} }
func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time, audit organizations.AuditEvent) error { 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 return organizations.ErrInvitationNotFound
} }
tx, err := store.db.BeginTx(ctx, nil) tx, err := store.db.BeginTx(ctx, nil)
@@ -198,16 +234,45 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
var invitationID, organizationID, directRole, invitedBy string // Serialize acceptance before reading token state, including competing users.
var teamIDsJSON []byte if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET expires_at=expires_at WHERE token_hash=?`, digest[:]); err != nil {
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) 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) { if errors.Is(err, sql.ErrNoRows) {
return organizations.ErrInvitationNotFound return organizations.ErrInvitationNotFound
} }
if err != nil { if err != nil {
return err 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 return err
} }
var teamIDs []string var teamIDs []string
@@ -222,11 +287,12 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
return err return err
} }
} }
if directRole != "" { for i, role := range roles {
if !safeName(directRole) { bindingID := "invite-" + invitationID
return organizations.ErrInvitationNotFound 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 { if err != nil {
return err return err
} }
@@ -241,9 +307,6 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
if changed, _ := result.RowsAffected(); changed != 1 { if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrInvitationNotFound return organizations.ErrInvitationNotFound
} }
if organizationID != audit.OrganizationID {
return organizations.ErrInvitationNotFound
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil { if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err 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 { 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) { if !validOrganization(value) || expectedRevision < 1 || value.Revision != expectedRevision+1 || !validOrganizationAudit(audit, value.ID) {
return errors.New("authsqlite: invalid organization update") return errors.New("authsqlite: invalid organization update")
} }
@@ -353,6 +427,11 @@ func (store *Store) UpdateOrganization(ctx context.Context, value organizations.
return err return err
} }
defer tx.Rollback() 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) 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 { if err != nil {
return err 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 { 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) { if !validMembershipStatusChange(input, ownerRole, audit) {
return organizations.ErrMembershipNotFound return organizations.ErrMembershipNotFound
} }
@@ -480,7 +567,12 @@ func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizati
return err return err
} }
defer tx.Rollback() 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 return err
} }
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID) 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 { if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
return err 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 { 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 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 { 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) { if !validMembershipRemoval(input, ownerRole, audit) {
return organizations.ErrMembershipNotFound return organizations.ErrMembershipNotFound
} }
@@ -562,7 +665,12 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz
return err return err
} }
defer tx.Rollback() 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 return err
} }
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID) 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 { if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
return err 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 { 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 return err
} }
@@ -597,13 +708,22 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz
return tx.Commit() 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 { func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID, actorUserID string) error {
// Acquire the SQLite write lock before reading the optimistic state. This // Acquire the SQLite write lock before reading the optimistic state. This
// makes a competing lifecycle transaction observe the committed winner. // makes a competing lifecycle transaction observe the committed winner.
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
WHERE organization_id=? AND user_id=? AND status='active' 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_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 { if err != nil {
return err return err
} }
@@ -613,6 +733,27 @@ func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID,
return nil 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) { func membershipStatus(ctx context.Context, tx *sql.Tx, organizationID, userID string) (string, error) {
var status string var status string
if err := tx.QueryRowContext(ctx, `SELECT status FROM gwf_organization_memberships WHERE organization_id=? AND user_id=?`, organizationID, userID).Scan(&status); err != nil { if 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) if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
FROM gwf_access_bindings b 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_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=? 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.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 { 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 { if !opaqueID(organizationID) || limit < 1 || limit > 1000 {
return nil, errors.New("authsqlite: invalid invitation query") 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 { if err != nil {
return nil, err return nil, err
} }
@@ -705,13 +846,16 @@ func (store *Store) Invitations(ctx context.Context, organizationID string, limi
for rows.Next() { for rows.Next() {
var value organizations.Invitation var value organizations.Invitation
var created, expires, used, revoked int64 var created, expires, used, revoked int64
var teamIDs []byte var teamIDs, rolesJSON []byte
if err = rows.Scan(&value.ID, &value.Email, &value.InvitedByUserID, &value.DirectRole, &teamIDs, &created, &expires, &used, &revoked); err != nil { 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 return nil, err
} }
if json.Unmarshal(teamIDs, &value.TeamIDs) != nil || !validInvitationTeamIDs(value.TeamIDs) { if json.Unmarshal(teamIDs, &value.TeamIDs) != nil || !validInvitationTeamIDs(value.TeamIDs) {
return nil, errors.New("authsqlite: stored invitation is invalid") 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.OrganizationID = organizationID
value.CreatedAt, value.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC() value.CreatedAt, value.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
if used != 0 { if used != 0 {
@@ -742,6 +886,14 @@ func validInvitationTeamIDs(teamIDs []string) bool {
return true 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 { func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID string, teamIDs []string) error {
for _, teamID := range teamIDs { for _, teamID := range teamIDs {
var count int 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 { if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err return err
} }
var directRole string var invitation organizations.Invitation
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 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) { if errors.Is(err, sql.ErrNoRows) {
return organizations.ErrInvitationNotFound return organizations.ErrInvitationNotFound
} }
return err return err
} }
if ownerRole != "" && directRole == ownerRole { if !decodeInvitationRoles(&invitation, rolesJSON) {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, ownerRole) 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 { if ownerErr != nil {
return ownerErr return ownerErr
} }
+175
View File
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"testing"
"gamertan.com/web/organizations"
)
type ownedOperation struct {
name, action string
apply func(context.Context, roleSetFixture, string, string) error
}
func ownedOperations() []ownedOperation {
return []ownedOperation{
{"profile", "organization.update", func(ctx context.Context, f roleSetFixture, actor, _ string) error {
_, err := f.organizations.UpdateOwnedOrganization(ctx, organizations.UpdateOrganization{ID: f.org.ID, Slug: "updated-business", Name: "Updated business", ActorUserID: actor, ExpectedRevision: 1, RequestID: "request-profile"})
return err
}},
{"suspend", "membership.suspended", func(ctx context.Context, f roleSetFixture, actor, target string) error {
return f.organizations.ChangeOwnedMembershipStatus(ctx, organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, ExpectedStatus: "active", Status: "suspended", RequestID: "request-status"})
}},
{"remove", "membership.remove", func(ctx context.Context, f roleSetFixture, actor, target string) error {
return f.organizations.RemoveOwnedMembershipIfCurrent(ctx, organizations.MembershipRemoval{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, ExpectedStatus: "active", RequestID: "request-remove"})
}},
}
}
func TestOwnedManagementRechecksActorInWriteTransaction(t *testing.T) {
for _, operation := range ownedOperations() {
for _, change := range []struct{ name, sql string }{
{"role revoked", `UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id='customer-12345'`},
{"role narrowed", `UPDATE gwf_access_bindings SET project_id=(SELECT id FROM gwf_projects LIMIT 1) WHERE subject_id='customer-12345'`},
{"actor suspended", `UPDATE gwf_organization_memberships SET status='suspended' WHERE user_id='customer-12345'`},
{"actor removed", `DELETE FROM gwf_organization_memberships WHERE user_id='customer-12345'`},
{"account disabled", `UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`},
{"registration incomplete", `UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`},
{"organization archived", `UPDATE gwf_organizations SET status='archived'`},
{"personal organization", `UPDATE gwf_organizations SET personal=1,personal_owner_user_id='customer-12345'`},
} {
t.Run(operation.name+"/"+change.name, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
if change.name == "role narrowed" {
if _, err := f.organizations.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: f.org.ID, Slug: "project", Name: "Project"}); err != nil {
t.Fatal(err)
}
}
// Model a change committed after the caller displayed/authorized the
// operation. The repository must not rely on that earlier decision.
if _, err := f.store.db.Exec(change.sql); err != nil {
t.Fatal(err)
}
if err := operation.apply(t.Context(), f, roleOwner, roleMember); err == nil {
t.Fatal("stale owner authority accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organizations WHERE id=? AND revision=1`, f.org.ID, 1)
})
}
}
}
func TestOwnedManagementDoesNotInheritDelegatedAdministratorSemantics(t *testing.T) {
for _, operation := range ownedOperations() {
t.Run(operation.name, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
if err := operation.apply(t.Context(), f, roleMember, roleMember); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner management: %v", err)
}
if err := operation.apply(t.Context(), f, roleOwner, roleMember); err != nil {
t.Fatal(err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1)
if err := operation.apply(t.Context(), f, roleOwner, roleMember); err == nil {
t.Fatal("replayed mutation accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1)
})
}
// Legacy callers still authorize non-owner administrative operations in
// their application policy; the new explicit methods do not alter that API.
f := newRoleSetFixture(t)
f.addMember(t)
err := f.organizations.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleMember, ExpectedStatus: "active", Status: "suspended"})
if err != nil {
t.Fatalf("delegated legacy operation changed: %v", err)
}
}
func TestOwnedMembershipPreservesLastOwnerAndRestoresSuspendedMember(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
for _, operation := range ownedOperations()[1:] {
if err := operation.apply(t.Context(), f, roleOwner, roleOwner); !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("%s last owner: %v", operation.name, err)
}
}
if err := ownedOperations()[1].apply(t.Context(), f, roleOwner, roleMember); err != nil {
t.Fatal(err)
}
input := organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, ExpectedStatus: "suspended", Status: "active"}
if err := f.organizations.ChangeOwnedMembershipStatus(t.Context(), input); err != nil {
t.Fatal(err)
}
if err := f.organizations.ChangeOwnedMembershipStatus(t.Context(), input); !errors.Is(err, organizations.ErrRevisionConflict) {
t.Fatalf("stale reactivation: %v", err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1)
}
func TestOwnedManagementRollsBackWithAuditFailure(t *testing.T) {
for _, operation := range ownedOperations() {
t.Run(operation.name, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
_, pending := f.invite(t, "buyer")
team, err := f.organizations.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: f.org.ID, Slug: "team", Name: "Team", ActorUserID: roleOwner})
if err != nil {
t.Fatal(err)
}
if err = f.organizations.AddTeamMember(t.Context(), team.ID, roleMember, roleOwner); err != nil {
t.Fatal(err)
}
if _, err = f.store.db.Exec(`CREATE TRIGGER reject_owned_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='` + operation.action + `' BEGIN SELECT RAISE(ABORT,'injected audit failure'); END`); err != nil {
t.Fatal(err)
}
if err = operation.apply(t.Context(), f, roleOwner, roleMember); err == nil {
t.Fatal("audit failure accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND revoked_at IS NULL`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organizations WHERE id=? AND revision=1`, f.org.ID, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NULL`, pending.ID, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 0)
})
}
}
func TestConcurrentOwnedManagementHasOneWinner(t *testing.T) {
for _, operation := range ownedOperations() {
t.Run(operation.name, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
start, results := make(chan struct{}), make(chan error, 2)
for range 2 {
go func() { <-start; results <- operation.apply(t.Context(), f, roleOwner, roleMember) }()
}
close(start)
success, stale := 0, 0
for range 2 {
err := <-results
switch {
case err == nil:
success++
case errors.Is(err, organizations.ErrRevisionConflict), errors.Is(err, organizations.ErrMembershipNotFound):
stale++
default:
t.Fatalf("concurrent mutation: %v", err)
}
}
if success != 1 || stale != 1 {
t.Fatalf("success=%d stale=%d", success, stale)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1)
})
}
}
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"gamertan.com/web/organizations"
)
// CreateOwnedOrganization atomically creates a new organization and its first
// direct owner. It never grants authority in an existing organization.
func (store *Store) CreateOwnedOrganization(ctx context.Context, setup organizations.OwnedOrganization) error {
organization, membership, binding := setup.Organization, setup.Membership, setup.OwnerBinding
audit, accessAudit := setup.OrganizationAudit, setup.AccessAudit
if !validOrganization(organization) || organization.Status != "active" || organization.Revision != 1 ||
membership.OrganizationID != organization.ID || !opaqueID(membership.UserID) || membership.Status != "active" || !membership.JoinedAt.Equal(organization.CreatedAt) ||
!validOwnerBinding(binding, organization.ID, membership.UserID) || !binding.GrantedAt.Equal(organization.CreatedAt) ||
!validOrganizationAudit(audit, organization.ID) || audit.ActorUserID != membership.UserID || audit.Action != "organization.create" || audit.ResourceType != "organization" || audit.ResourceID != organization.ID ||
!validAccessAudit(accessAudit) || accessAudit.OrganizationID != organization.ID || accessAudit.ActorUserID != membership.UserID || accessAudit.Action != "access.binding.grant" || accessAudit.ResourceType != "binding" || accessAudit.ResourceID != binding.ID || accessAudit.RequestID != audit.RequestID {
return errors.New("authsqlite: invalid owned organization")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var personalOwner any
if organization.Personal {
personalOwner = membership.UserID
}
// The first statement acquires the writer lock and validates active, completed
// identity inside the transaction; account suspension cannot race the grant.
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at)
SELECT ?,?,?,?,?,?,?,?,? FROM gwf_users WHERE id=? AND status='active' AND registration_pending=0`,
organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner,
organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix(), membership.UserID)
if err != nil {
return err
}
if changed, err := result.RowsAffected(); err != nil || changed != 1 {
if err != nil {
return err
}
return organizations.ErrOwnerAuthority
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, organization.ID, membership.UserID, membership.Status, membership.JoinedAt.Unix()); err != nil {
return err
}
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at)
SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`,
binding.ID, organization.ID, membership.UserID, binding.Role, membership.UserID, binding.GrantedAt.Unix(), binding.Role)
if err != nil {
return err
}
if changed, err := result.RowsAffected(); err != nil || changed != 1 {
if err != nil {
return err
}
return errors.New("authsqlite: initial owner role has not been seeded")
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, accessAudit); err != nil {
return err
}
return tx.Commit()
}
var _ organizations.OwnedOrganizationRepository = (*Store)(nil)
+264
View File
@@ -0,0 +1,264 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/organizations"
)
func ownedOrganizationFixture(t *testing.T) (*Store, *organizations.Service, access.Policy, organizations.CreateOrganization) {
t.Helper()
store, err := Open(filepath.Join(t.TempDir(), "owned.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
now := time.Unix(2000, 0).UTC()
if _, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,registration_pending,created_at,updated_at)
VALUES('customer-12345','customer','customer','customer@example.test','customer@example.test','Customer','active',0,2000,2000)`); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"customer.owner": "Customer owner", "home.owner": "Merchant owner"},
Permissions: map[string]string{"customer.purchase": "Purchase", "merchant.manage": "Manage merchant"},
Grants: map[string][]string{"customer.owner": {"customer.purchase"}, "home.owner": {"merchant.manage"}},
}
accessService, err := access.New(store, policy, access.Options{})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
service, err := organizations.New(store, organizations.Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
return store, service, policy, organizations.CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345", RequestID: "request-creation"}
}
func countOwnedRows(t *testing.T, store *Store, want int) {
t.Helper()
for _, table := range []string{"gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_access_audit_events"} {
var count int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil {
t.Fatal(err)
}
expected := want
if table == "gwf_access_audit_events" {
expected *= 2
}
if count != expected {
t.Errorf("%s count=%d want=%d", table, count, expected)
}
}
}
func TestOwnedOrganizationCommitsScopedOwnerAndAudits(t *testing.T) {
store, service, policy, input := ownedOrganizationFixture(t)
organization, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
countOwnedRows(t, store, 1)
accessService, err := access.New(store, policy, access.Options{})
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
scope string
permission string
want bool
}{
{organization.ID, "customer.purchase", true},
{organization.ID, "merchant.manage", false},
{"other-org-12345", "customer.purchase", false},
} {
decision, err := accessService.Authorize(t.Context(), input.OwnerUserID, access.Scope{OrganizationID: test.scope}, test.permission)
if err != nil || decision.Allowed != test.want {
t.Fatalf("scope=%s permission=%s decision=%+v err=%v", test.scope, test.permission, decision, err)
}
}
for _, action := range []string{"organization.create", "access.binding.grant"} {
var actor, request string
if err = store.db.QueryRow(`SELECT actor_user_id,request_id FROM gwf_access_audit_events WHERE organization_id=? AND action=?`, organization.ID, action).Scan(&actor, &request); err != nil {
t.Fatal(err)
}
if actor != input.OwnerUserID || request != input.RequestID {
t.Fatalf("audit actor=%q request=%q", actor, request)
}
}
if _, err = service.CreateOwnedOrganization(t.Context(), input); err == nil {
t.Fatal("duplicate slug accepted")
}
countOwnedRows(t, store, 1)
}
func TestOwnedOrganizationRollsBackEveryWriteFailure(t *testing.T) {
for _, stage := range []struct{ table, when string }{
{"gwf_organizations", ""}, {"gwf_organization_memberships", ""}, {"gwf_access_bindings", ""},
{"gwf_access_audit_events", " WHEN NEW.action='organization.create'"},
{"gwf_access_audit_events", " WHEN NEW.action='access.binding.grant'"},
} {
t.Run(stage.table+stage.when, func(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
if _, err := store.db.Exec(`CREATE TRIGGER reject_creation BEFORE INSERT ON ` + stage.table + stage.when + ` BEGIN SELECT RAISE(ABORT,'injected write failure'); END`); err != nil {
t.Fatal(err)
}
if organization, err := service.CreateOwnedOrganization(t.Context(), input); err == nil || organization.ID != "" {
t.Fatalf("organization=%+v err=%v", organization, err)
}
countOwnedRows(t, store, 0)
})
}
}
func TestOwnedOrganizationRejectsMissingRoleAndUnavailableOwner(t *testing.T) {
for _, change := range []string{
`DELETE FROM gwf_access_role_permissions WHERE role_name='customer.owner'; DELETE FROM gwf_access_roles WHERE name='customer.owner'`,
`UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`,
`UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`,
`DELETE FROM gwf_users WHERE id='customer-12345'`,
} {
t.Run(change, func(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
if _, err := store.db.Exec(change); err != nil {
t.Fatal(err)
}
if organization, err := service.CreateOwnedOrganization(t.Context(), input); err == nil || organization.ID != "" {
t.Fatalf("organization=%+v err=%v", organization, err)
}
countOwnedRows(t, store, 0)
})
}
}
func TestConcurrentOwnedOrganizationCreationHasOneCompleteWinner(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
var workers sync.WaitGroup
results := make(chan error, 8)
for range 8 {
workers.Go(func() { _, err := service.CreateOwnedOrganization(t.Context(), input); results <- err })
}
workers.Wait()
close(results)
winners := 0
for err := range results {
if err == nil {
winners++
}
}
if winners != 1 {
t.Fatalf("successful creations=%d", winners)
}
countOwnedRows(t, store, 1)
}
func TestLegacyOrganizationCreationRemainsMembershipOnly(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
if _, err := service.CreateOrganization(t.Context(), input); err != nil {
t.Fatal(err)
}
var bindings int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_bindings`).Scan(&bindings); err != nil {
t.Fatal(err)
}
if bindings != 0 {
t.Fatal("legacy creation unexpectedly granted authority")
}
}
func TestOwnedOrganizationSurvivesReopenAndProtectsLastOwner(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
organization, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
var sequence int
var name, path string
if err = store.db.QueryRow(`PRAGMA database_list`).Scan(&sequence, &name, &path); err != nil {
t.Fatal(err)
}
if err = store.Close(); err != nil {
t.Fatal(err)
}
reopened, err := OpenWithOptions(path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if err = reopened.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal(err)
}
countOwnedRows(t, reopened, 1)
service, err = organizations.New(reopened, organizations.Options{OwnerRole: "customer.owner"})
if err != nil {
t.Fatal(err)
}
err = service.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{
OrganizationID: organization.ID, UserID: input.OwnerUserID, ActorUserID: input.OwnerUserID, ExpectedStatus: "active",
})
if !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("last owner removal: %v", err)
}
err = service.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{
OrganizationID: organization.ID, UserID: input.OwnerUserID, ActorUserID: input.OwnerUserID, ExpectedStatus: "active", Status: "suspended",
})
if !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("last owner suspension: %v", err)
}
countOwnedRows(t, reopened, 1)
}
type capturedOwnedStore struct {
*Store
setup organizations.OwnedOrganization
}
func (store *capturedOwnedStore) CreateOwnedOrganization(_ context.Context, setup organizations.OwnedOrganization) error {
store.setup = setup
return nil
}
func TestOwnedOrganizationRejectsMismatchedAuthorityAndAudits(t *testing.T) {
for _, test := range []struct {
name string
change func(*organizations.OwnedOrganization)
}{
{"foreign member", func(s *organizations.OwnedOrganization) { s.Membership.OrganizationID = "other-org-12345" }},
{"foreign owner", func(s *organizations.OwnedOrganization) { s.OwnerBinding.SubjectID = "other-user-12345" }},
{"foreign scope", func(s *organizations.OwnedOrganization) { s.OwnerBinding.Scope.OrganizationID = "other-org-12345" }},
{"narrow scope", func(s *organizations.OwnedOrganization) { s.OwnerBinding.Scope.ProjectID = "project-12345" }},
{"team owner", func(s *organizations.OwnedOrganization) { s.OwnerBinding.SubjectKind = access.Team }},
{"wrong audit actor", func(s *organizations.OwnedOrganization) { s.AccessAudit.ActorUserID = "other-user-12345" }},
{"wrong audit binding", func(s *organizations.OwnedOrganization) { s.AccessAudit.ResourceID = "other-binding-12345" }},
{"wrong creation resource", func(s *organizations.OwnedOrganization) { s.OrganizationAudit.ResourceID = "other-org-12345" }},
{"wrong request", func(s *organizations.OwnedOrganization) { s.AccessAudit.RequestID = "other-request" }},
{"archived organization", func(s *organizations.OwnedOrganization) { s.Organization.Status = "archived" }},
} {
t.Run(test.name, func(t *testing.T) {
store, _, _, input := ownedOrganizationFixture(t)
capture := &capturedOwnedStore{Store: store}
service, err := organizations.New(capture, organizations.Options{OwnerRole: "customer.owner"})
if err != nil {
t.Fatal(err)
}
if _, err = service.CreateOwnedOrganization(t.Context(), input); err != nil {
t.Fatal(err)
}
test.change(&capture.setup)
if err = store.CreateOwnedOrganization(t.Context(), capture.setup); err == nil {
t.Fatal("invalid creation accepted")
}
countOwnedRows(t, store, 0)
})
}
}
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"errors"
"math"
"time"
"gamertan.com/web/auth"
"modernc.org/sqlite"
)
var _ auth.OwnProfileRepository = (*Store)(nil)
const ownProfileQuery = `SELECT u.id,u.username,u.email,u.display_name,u.profile_revision
FROM gwf_users u JOIN gwf_auth_sessions s ON s.user_id=u.id
WHERE s.token_hash=? AND s.expires_at>? AND u.status='active'
AND u.registration_pending=0 AND u.password_change_required=0`
func scanOwnProfile(row interface{ Scan(...any) error }) (auth.OwnProfile, error) {
var profile auth.OwnProfile
err := row.Scan(&profile.UserID, &profile.Username, &profile.Email, &profile.DisplayName, &profile.Revision)
if errors.Is(err, sql.ErrNoRows) {
return auth.OwnProfile{}, auth.ErrProfileAccess
}
return profile, err
}
func (store *Store) OwnProfile(ctx context.Context, session [32]byte, now time.Time) (auth.OwnProfile, error) {
if zeroDigest(session) || now.IsZero() {
return auth.OwnProfile{}, auth.ErrProfileAccess
}
return scanOwnProfile(store.db.QueryRowContext(ctx, ownProfileQuery, session[:], now.Unix()))
}
func (store *Store) UpdateOwnProfile(ctx context.Context, change auth.ProfileEdit, audit auth.AuditEvent) (auth.OwnProfile, error) {
value, err := auth.NormalizeProfileValue(change.Field, change.Value)
if err != nil || !opaqueID(change.UserID) || zeroDigest(change.SessionDigest) || change.ExpectedRevision < 1 || change.ExpectedRevision == math.MaxInt64 ||
!validAuditEvent(audit) || audit.ActorUserID != change.UserID || audit.ResourceType != "user" || audit.ResourceID != change.UserID || audit.Action != "auth.profile."+change.Field ||
change.ExpectedPasswordHash != "" && (change.Field != "username" || len(change.ExpectedPasswordHash) > 1024) {
return auth.OwnProfile{}, auth.ErrProfileInput
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.OwnProfile{}, err
}
defer tx.Rollback()
// This first statement takes the writer lock and tests the real current
// session/account/revision together. No read-before-write lock upgrade race.
set := `display_name=?`
args := []any{value}
if change.Field == "username" {
set = `username=?,username_normalized=?`
args = append(args, normalize(value))
}
args = append(args, audit.CreatedAt.Unix(), change.UserID, change.ExpectedRevision, change.SessionDigest[:], audit.CreatedAt.Unix(), change.ExpectedPasswordHash, change.ExpectedPasswordHash)
result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET `+set+`,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?)
WHERE id=? AND profile_revision=? AND status='active' AND registration_pending=0 AND password_change_required=0
AND EXISTS (SELECT 1 FROM gwf_auth_sessions WHERE user_id=gwf_users.id AND token_hash=? AND expires_at>?)
AND (?='' OR EXISTS (SELECT 1 FROM gwf_password_credentials WHERE user_id=gwf_users.id AND password_hash=?))`, args...)
if err != nil {
var constraint *sqlite.Error
if errors.As(err, &constraint) && constraint.Code() == 2067 {
return auth.OwnProfile{}, auth.ErrUsernameUnavailable
}
return auth.OwnProfile{}, err
}
changed, err := result.RowsAffected()
if err != nil {
return auth.OwnProfile{}, err
}
if changed != 1 {
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
if err != nil {
return auth.OwnProfile{}, err
}
if profile.UserID != change.UserID {
return auth.OwnProfile{}, auth.ErrProfileAccess
}
return auth.OwnProfile{}, auth.ErrProfileConflict
}
if change.Field == "username" {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=? AND token_hash<>?`, change.UserID, change.SessionDigest[:]); err != nil {
return auth.OwnProfile{}, err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return auth.OwnProfile{}, err
}
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
if err != nil {
return auth.OwnProfile{}, err
}
if err = tx.Commit(); err != nil {
return auth.OwnProfile{}, err
}
return profile, nil
}
+212
View File
@@ -0,0 +1,212 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"crypto/sha256"
"errors"
"path/filepath"
"sync"
"testing"
"time"
"gamertan.com/web/auth"
)
type profileFixture struct {
store *Store
path string
now time.Time
user auth.User
session, other auth.Session
}
func newProfileFixture(t *testing.T) profileFixture {
t.Helper()
path := filepath.Join(t.TempDir(), "identity.sqlite")
store, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
now := time.Now().UTC().Truncate(time.Second)
user := auth.User{ID: "profile-user", Username: "profile.reader", Email: "profile@example.test", DisplayName: "Profile Reader", Status: "active", CreatedAt: now, UpdatedAt: now}
if err = store.CreateUser(t.Context(), user, "fixture-hash"); err != nil {
t.Fatal(err)
}
session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-session")), CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour)}
other := session
other.Digest = sha256.Sum256([]byte("other-session"))
for _, s := range []auth.Session{session, other} {
if err = store.CreateSession(t.Context(), s); err != nil {
t.Fatal(err)
}
}
return profileFixture{store, path, now, user, session, other}
}
func (f profileFixture) change(field, value string, revision int64) (auth.ProfileEdit, auth.AuditEvent) {
return auth.ProfileEdit{UserID: f.user.ID, SessionDigest: f.session.Digest, ExpectedRevision: revision, Field: field, Value: value},
auth.AuditEvent{ID: "profile-audit-" + field, ActorUserID: f.user.ID, Action: "auth.profile." + field, ResourceType: "user", ResourceID: f.user.ID, Summary: "Own profile field changed", CreatedAt: f.now}
}
func TestOwnProfileStableIdentityAndSessionPolicy(t *testing.T) {
f := newProfileFixture(t)
initial, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now)
if err != nil || initial.Revision != 1 {
t.Fatalf("initial revision: %d %v", initial.Revision, err)
}
change, audit := f.change("display_name", " Émilie ★ ", 1)
updated, err := f.store.UpdateOwnProfile(t.Context(), change, audit)
if err != nil || updated.DisplayName != "Émilie ★" || updated.Revision != 2 || updated.UserID != initial.UserID || updated.Username != initial.Username || updated.Email != initial.Email {
t.Fatalf("display update: %+v %v", updated, err)
}
if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil {
t.Fatal("display edit revoked session", err)
}
if _, err = f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) {
t.Fatalf("stale: %v", err)
}
change, audit = f.change("username", "new.reader", 2)
change.ExpectedPasswordHash = "fixture-hash"
updated, err = f.store.UpdateOwnProfile(t.Context(), change, audit)
if err != nil || updated.Username != "new.reader" || updated.Revision != 3 || updated.UserID != initial.UserID || updated.Email != initial.Email {
t.Fatalf("username update: %+v %v", updated, err)
}
if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); !errors.Is(err, auth.ErrProfileAccess) {
t.Fatalf("other session survived: %v", err)
}
user, hash, err := f.store.CredentialByIdentifier(t.Context(), "NEW.READER")
if err != nil || user.ID != initial.UserID || hash != "fixture-hash" {
t.Fatal("credential identity changed", err)
}
var count int
if err = f.store.db.QueryRow(`SELECT count(*) FROM gwf_audit_events WHERE actor_user_id=? AND resource_id=?`, f.user.ID, f.user.ID).Scan(&count); err != nil || count != 2 {
t.Fatalf("audits: %d %v", count, err)
}
reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if recovered, err := reopened.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || recovered != updated {
t.Fatalf("restart: %+v %v", recovered, err)
}
}
func TestOwnProfileAuthorizationAndRollback(t *testing.T) {
for _, test := range []struct{ name, sql string }{
{"revoked-session", `DELETE FROM gwf_auth_sessions`},
{"expired-session", `UPDATE gwf_auth_sessions SET expires_at=1`},
{"suspended", `UPDATE gwf_users SET status='suspended'`},
{"disabled", `UPDATE gwf_users SET status='disabled'`},
{"registration-pending", `UPDATE gwf_users SET registration_pending=1`},
{"password-change", `UPDATE gwf_users SET password_change_required=1`},
} {
t.Run(test.name, func(t *testing.T) {
f := newProfileFixture(t)
if _, err := f.store.db.Exec(test.sql); err != nil {
t.Fatal(err)
}
change, audit := f.change("display_name", "not allowed", 1)
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) {
t.Fatalf("access: %v", err)
}
})
}
f := newProfileFixture(t)
change, audit := f.change("username", "new.reader", 1)
change.UserID = "another-user"
audit.ActorUserID = change.UserID
audit.ResourceID = change.UserID
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) {
t.Fatalf("foreign user: %v", err)
}
change, audit = f.change("username", "new.reader", 1)
change.ExpectedPasswordHash = "old-verified-hash"
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) {
t.Fatalf("changed password: %v", err)
}
change.ExpectedPasswordHash = "fixture-hash"
if err := f.store.AppendAudit(t.Context(), audit); err != nil {
t.Fatal(err)
}
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); err == nil {
t.Fatal("duplicate audit accepted")
}
if profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || profile.Revision != 1 || profile.Username != f.user.Username {
t.Fatalf("rollback: %+v %v", profile, err)
}
if _, err := f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil {
t.Fatal("audit failure revoked session", err)
}
}
func TestOwnProfileUniquenessConcurrencyAndMigration(t *testing.T) {
f := newProfileFixture(t)
otherUser := f.user
otherUser.ID = "another-user"
otherUser.Username = "another.reader"
otherUser.Email = "another@example.test"
if err := f.store.CreateUser(t.Context(), otherUser, "fixture-hash"); err != nil {
t.Fatal(err)
}
change, audit := f.change("username", "ANOTHER.READER", 1)
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrUsernameUnavailable) {
t.Fatalf("unique name: %v", err)
}
second, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer second.Close()
var wg sync.WaitGroup
results := make(chan error, 2)
for _, store := range []*Store{f.store, second} {
wg.Add(1)
go func(store *Store) {
defer wg.Done()
change, audit := f.change("display_name", "New Name", 1)
_, err := store.UpdateOwnProfile(t.Context(), change, audit)
results <- err
}(store)
}
wg.Wait()
close(results)
success, conflict := 0, 0
for err := range results {
if err == nil {
success++
} else if errors.Is(err, auth.ErrProfileConflict) {
conflict++
} else {
t.Fatal(err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("concurrent writes: %d successes, %d conflicts", success, conflict)
}
// Recreate the actual previous schema without rewriting its identity rows.
if _, err = f.store.db.Exec(`ALTER TABLE gwf_users DROP COLUMN profile_revision`); err != nil {
t.Fatal(err)
}
if _, err = f.store.db.Exec(`DELETE FROM gamertan_web_migrations WHERE version=11`); err != nil {
t.Fatal(err)
}
if version, err := f.store.CurrentSchema(t.Context()); err != nil || version != 10 {
t.Fatalf("prior schema: %d %v", version, err)
}
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
t.Fatal("startup accepted old schema")
}
if err = f.store.Migrate(t.Context()); err != nil {
t.Fatal(err)
}
profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now)
if err != nil || profile.UserID != f.user.ID || profile.Email != f.user.Email || profile.DisplayName != "New Name" || profile.Revision != 1 {
t.Fatalf("migration: %+v %v", profile, err)
}
if err = f.store.Migrate(t.Context()); err != nil {
t.Fatal("idempotent migration", err)
}
}
+488
View File
@@ -0,0 +1,488 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"errors"
"slices"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/organizations"
)
type roleSetFixture struct {
store *Store
access *access.Service
organizations *organizations.Service
org organizations.Organization
now time.Time
}
const roleOwner = "customer-12345"
const roleMember = "member-12345678"
func newRoleSetFixture(t *testing.T) roleSetFixture {
t.Helper()
store, _, policy, input := ownedOrganizationFixture(t)
policy.Roles["buyer"] = "Buyer"
policy.Roles["billing"] = "Billing manager"
policy.Roles["member"] = "Member"
policy.Permissions["billing.manage"] = "Manage billing"
policy.Grants["buyer"] = []string{"customer.purchase"}
policy.Grants["billing"] = []string{"billing.manage"}
policy.Grants["member"] = nil
now := time.Unix(2100, 0).UTC()
accessService, err := access.New(store, policy, access.Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
service, err := organizations.New(store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
org, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
if _, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,registration_pending,created_at,updated_at)
VALUES(?,?,?,?,?,?,'active',0,2000,2000)`, roleMember, "member", "member", "member@example.test", "member@example.test", "Member"); err != nil {
t.Fatal(err)
}
return roleSetFixture{store: store, access: accessService, organizations: service, org: org, now: now}
}
func (f roleSetFixture) invite(t *testing.T, roles ...string) (string, organizations.Invitation) {
t.Helper()
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRoles: roles, Lifetime: time.Hour, RequestID: "request-invite"})
if err != nil {
t.Fatal(err)
}
return raw, invitation
}
func (f roleSetFixture) addMember(t *testing.T) {
t.Helper()
raw, _ := f.invite(t, "member")
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
t.Fatal(err)
}
}
func (f roleSetFixture) bindings(t *testing.T, user string) ([]string, []string) {
t.Helper()
bindings, err := f.access.OrganizationUserBindings(t.Context(), f.org.ID, 100)
if err != nil {
t.Fatal(err)
}
var ids, roles []string
for _, binding := range bindings {
if binding.SubjectID == user {
ids = append(ids, binding.ID)
roles = append(roles, binding.Role)
}
}
slices.Sort(ids)
slices.Sort(roles)
return ids, roles
}
func (f roleSetFixture) change(t *testing.T, actor, target string, roles ...string) error {
t.Helper()
ids, _ := f.bindings(t, target)
_, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, RequestID: "request-roles", Roles: roles, ExpectedBindingIDs: ids})
return err
}
func TestRoleSetCombinesCapabilitiesWithoutNarrowGrantChanges(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
project, err := f.organizations.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: f.org.ID, Slug: "project", Name: "Project"})
if err != nil {
t.Fatal(err)
}
narrow, err := f.access.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: roleMember, Role: "member", Scope: access.Scope{OrganizationID: f.org.ID, ProjectID: project.ID}, GrantedBy: roleOwner})
if err != nil {
t.Fatal(err)
}
if err = f.change(t, roleOwner, roleMember, "buyer", "billing"); err != nil {
t.Fatal(err)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v", roles)
}
for _, permission := range []string{"customer.purchase", "billing.manage"} {
decision, err := f.access.Authorize(t.Context(), roleMember, access.Scope{OrganizationID: f.org.ID}, permission)
if err != nil || !decision.Allowed {
t.Fatalf("permission=%s allowed=%v err=%v", permission, decision.Allowed, err)
}
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=? AND revoked_at IS NULL`, narrow.ID, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1)
if err = f.change(t, roleOwner, roleMember, "billing", "buyer"); !errors.Is(err, access.ErrRoleUnchanged) {
t.Fatalf("unchanged=%v", err)
}
if err = f.change(t, roleMember, roleMember, "member"); !errors.Is(err, access.ErrOwnerAuthority) {
t.Fatalf("non-owner bulk change=%v", err)
}
if err = f.change(t, roleOwner, roleOwner, "billing", "buyer"); !errors.Is(err, access.ErrLastOwner) {
t.Fatalf("last owner=%v", err)
}
if err = f.change(t, roleOwner, roleMember, "customer.owner", "billing"); err != nil {
t.Fatal(err)
}
if err = f.change(t, roleMember, roleOwner, "buyer"); err != nil {
t.Fatal(err)
}
if err = f.change(t, roleMember, roleMember, "buyer"); !errors.Is(err, access.ErrLastOwner) {
t.Fatalf("new last owner=%v", err)
}
}
func TestRoleSetConcurrentChangesHaveOneWinner(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
ids, _ := f.bindings(t, roleMember)
start := make(chan struct{})
results := make(chan error, 2)
for range 2 {
go func() {
<-start
_, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, Roles: []string{"buyer", "billing"}, ExpectedBindingIDs: ids})
results <- err
}()
}
close(start)
success, conflict := 0, 0
for range 2 {
err := <-results
switch {
case err == nil:
success++
case errors.Is(err, access.ErrRoleChangeConflict):
conflict++
default:
t.Fatalf("concurrent error=%v", err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("success=%d conflict=%d", success, conflict)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v", roles)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1)
}
func TestRoleSetRollsBackRevocationAndPartialInsert(t *testing.T) {
for _, stage := range []string{"second binding", "audit", "missing role"} {
t.Run(stage, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
before, _ := f.bindings(t, roleMember)
var statement string
switch stage {
case "second binding":
statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'write failure'); END`
case "audit":
statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='access.role.replace' BEGIN SELECT RAISE(ABORT,'audit failure'); END`
case "missing role":
statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'`
}
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
if err := f.change(t, roleOwner, roleMember, "billing", "buyer"); err == nil {
t.Fatal("write failure accepted")
}
after, roles := f.bindings(t, roleMember)
if !slices.Equal(before, after) || !slices.Equal(roles, []string{"member"}) {
t.Fatalf("after=%v roles=%v", after, roles)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 0)
})
}
}
func TestRoleInvitationPreservesRolesAuthorityAndSingleUse(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation := f.invite(t, "buyer", "billing")
stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now)
if err != nil {
t.Fatal(err)
}
if stored.RequiredOwnerRole != "customer.owner" || stored.DirectRole != "" || !slices.Equal(stored.DirectRoles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v authority=%q", stored.DirectRoles, stored.RequiredOwnerRole)
}
listed, err := f.organizations.Invitations(t.Context(), f.org.ID, 10)
if err != nil || len(listed) != 1 || !slices.Equal(listed[0].DirectRoles, stored.DirectRoles) {
t.Fatalf("listed=%v err=%v", listed, err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.create' AND request_id='request-invite'`, invitation.ID, 1)
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleOwner); err == nil {
t.Fatal("wrong email accepted")
}
start := make(chan struct{})
results := make(chan error, 2)
for range 2 {
go func() { <-start; results <- f.organizations.AcceptInvitation(t.Context(), raw, roleMember) }()
}
close(start)
success := 0
for range 2 {
if err := <-results; err == nil {
success++
} else if !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("accept error=%v", err)
}
}
if success != 1 {
t.Fatalf("accepted=%d", success)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v", roles)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 1)
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("replay=%v", err)
}
}
func TestRoleInvitationRechecksGrantorAndRecipient(t *testing.T) {
for _, mutation := range []string{
`UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id='customer-12345'`,
`UPDATE gwf_organization_memberships SET status='suspended' WHERE user_id='customer-12345'`,
`UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`,
`UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`,
`DELETE FROM gwf_organization_memberships WHERE user_id='customer-12345'`,
`UPDATE gwf_users SET status='disabled' WHERE id='member-12345678'`,
`UPDATE gwf_users SET registration_pending=1 WHERE id='member-12345678'`,
`UPDATE gwf_organizations SET status='archived'`,
`UPDATE gwf_organization_invitations SET expires_at=2100`,
`UPDATE gwf_organization_invitations SET revoked_at=2100`,
`UPDATE gwf_organization_invitations SET direct_roles_json='["buyer","buyer"]'`,
} {
t.Run(mutation, func(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation := f.invite(t, "buyer", "billing")
if _, err := f.store.db.Exec(mutation); err != nil {
t.Fatal(err)
}
// Stored authority still applies through a differently configured service.
other, err := organizations.New(f.store, organizations.Options{Now: func() time.Time { return f.now }})
if err != nil {
t.Fatal(err)
}
if err = other.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
t.Fatal("stale or invalid authority accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0)
})
}
}
func TestRoleInvitationRejectsImplicitReactivationAndNonOwnerManagement(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
if _, _, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "new@example.test", InvitedByUserID: roleMember, DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("member invite=%v", err)
}
raw, invitation := f.invite(t, "buyer", "billing")
if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleMember, "request-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("member revoke=%v", err)
}
if err := f.organizations.SetMembershipStatus(t.Context(), f.org.ID, roleMember, "suspended", roleOwner, "request-suspend"); err != nil {
t.Fatal(err)
}
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
t.Fatal("invitation reactivated suspended member")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='suspended'`, roleMember, 1)
if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleOwner, "request-revoke-owner"); err != nil {
t.Fatal(err)
}
}
func TestRoleInvitationAcceptanceRollsBackEveryWrite(t *testing.T) {
for _, stage := range []string{"membership", "binding", "audit", "missing role"} {
t.Run(stage, func(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation := f.invite(t, "buyer", "billing")
var statement string
switch stage {
case "membership":
statement = `CREATE TRIGGER fail_member BEFORE INSERT ON gwf_organization_memberships BEGIN SELECT RAISE(ABORT,'membership failure'); END`
case "binding":
statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'binding failure'); END`
case "audit":
statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.accept' BEGIN SELECT RAISE(ABORT,'audit failure'); END`
case "missing role":
statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'`
}
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
t.Fatal("partial acceptance succeeded")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 0)
})
}
}
func TestRoleInvitationMigrationPreservesLegacyAndRequiresExplicitMigration(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRole: "customer.owner", Lifetime: time.Hour})
if err != nil {
t.Fatal(err)
}
// Reconstruct the previous invitation schema in this disposable database.
if _, err = f.store.db.Exec(`ALTER TABLE gwf_organization_invitations DROP COLUMN direct_roles_json;
ALTER TABLE gwf_organization_invitations DROP COLUMN required_owner_role;
ALTER TABLE gwf_users DROP COLUMN profile_revision;
DELETE FROM gamertan_web_migrations WHERE version>=10`); err != nil {
t.Fatal(err)
}
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
t.Fatal("old schema accepted without migration")
}
for range 2 {
if err = f.store.Migrate(t.Context()); err != nil {
t.Fatal(err)
}
}
if err = f.store.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal(err)
}
stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now)
if err != nil || stored.DirectRole != "customer.owner" || len(stored.DirectRoles) != 0 || stored.RequiredOwnerRole != "" {
t.Fatalf("legacy changed: %+v err=%v", stored, err)
}
if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id=?`, roleOwner); err != nil {
t.Fatal(err)
}
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("legacy owner authority=%v", err)
}
if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=NULL WHERE subject_id=?`, roleOwner); err != nil {
t.Fatal(err)
}
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
t.Fatal(err)
}
ids, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"customer.owner"}) || !slices.Equal(ids, []string{"invite-" + invitation.ID}) {
t.Fatalf("legacy IDs=%v roles=%v", ids, roles)
}
}
func TestRoleInvitationUsesAdvancingClockAndBoundAudit(t *testing.T) {
f := newRoleSetFixture(t)
service, err := organizations.New(f.store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true})
if err != nil {
t.Fatal(err)
}
raw, invitation, err := service.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: []string{"billing", "buyer"}, Lifetime: time.Hour})
if err != nil {
t.Fatal(err)
}
audit := organizations.AuditEvent{ID: "audit-accept-12345", OrganizationID: f.org.ID, ActorUserID: roleMember, Action: "invitation.accept", ResourceType: "invitation", ResourceID: invitation.ID, Summary: "Invitation accepted", CreatedAt: time.Now().UTC()}
for _, field := range []string{"actor", "resource", "action"} {
bad := audit
switch field {
case "actor":
bad.ActorUserID = roleOwner
case "resource":
bad.ResourceID = "invitation-other"
case "action":
bad.Action = "invitation.create"
}
if err = f.store.AcceptInvitationWithRoles(t.Context(), invitation.Digest, roleMember, "customer.owner", time.Now().UTC(), bad); err == nil {
t.Fatalf("mismatched audit %s accepted", field)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
}
if err = service.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
t.Fatalf("real clock acceptance=%v", err)
}
}
func TestRoleInvitationCreationIsAtomicAndRejectsUnknownRole(t *testing.T) {
for _, fail := range []string{"unknown role", "audit"} {
t.Run(fail, func(t *testing.T) {
f := newRoleSetFixture(t)
roles := []string{"billing", "buyer"}
if fail == "unknown role" {
roles = append(roles, "unknown")
} else if _, err := f.store.db.Exec(`CREATE TRIGGER fail_invite BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.create' BEGIN SELECT RAISE(ABORT,'audit failure'); END`); err != nil {
t.Fatal(err)
}
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: roles, Lifetime: time.Hour})
if err == nil || raw != "" || invitation.ID != "" {
t.Fatal("failed creation returned invitation")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE organization_id=?`, f.org.ID, 0)
})
}
}
func TestRoleInvitationCannotBypassMembershipChanges(t *testing.T) {
for _, optimistic := range []bool{false, true} {
t.Run(map[bool]string{false: "legacy removal", true: "optimistic removal"}[optimistic], func(t *testing.T) {
f := newRoleSetFixture(t)
oldToken, oldInvitation := f.invite(t, "buyer", "billing")
f.addMember(t)
if err := f.organizations.AcceptInvitation(t.Context(), oldToken, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("old invite elevated existing member=%v", err)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"member"}) {
t.Fatalf("roles changed=%v", roles)
}
remove := func() error {
if optimistic {
return f.organizations.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, ExpectedStatus: "active", RequestID: "request-remove"})
}
return f.organizations.RemoveMembership(t.Context(), f.org.ID, roleMember, roleOwner, "request-remove")
}
if _, err := f.store.db.Exec(`CREATE TRIGGER fail_removal BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='membership.remove' BEGIN SELECT RAISE(ABORT,'audit failure'); END`); err != nil {
t.Fatal(err)
}
if err := remove(); err == nil {
t.Fatal("unaudited removal succeeded")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NULL AND used_at IS NULL`, oldInvitation.ID, 1)
if _, err := f.store.db.Exec(`DROP TRIGGER fail_removal`); err != nil {
t.Fatal(err)
}
if err := remove(); err != nil {
t.Fatal(err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NOT NULL AND used_at IS NULL`, oldInvitation.ID, 1)
if err := f.organizations.AcceptInvitation(t.Context(), oldToken, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("old invite restored removed member=%v", err)
}
newToken, _ := f.invite(t, "buyer")
if err := f.organizations.AcceptInvitation(t.Context(), newToken, roleMember); err != nil {
t.Fatalf("intentional fresh invitation=%v", err)
}
_, roles = f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"buyer"}) {
t.Fatalf("fresh roles=%v", roles)
}
})
}
}
+10 -1
View File
@@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
return store, nil return store, nil
} }
const SchemaVersion = 9 const SchemaVersion = 11
func (store *Store) CurrentSchema(ctx context.Context) (int, error) { func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
var exists int var exists int
@@ -179,6 +179,7 @@ func (store *Store) Migrate(ctx context.Context) error {
table, column, definition string table, column, definition string
}{ }{
{"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`}, {"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", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`},
{"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`}, {"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
{"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 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", "revoked_at", `INTEGER`},
{"gwf_organization_invitations", "direct_role", `TEXT NOT NULL DEFAULT ''`}, {"gwf_organization_invitations", "direct_role", `TEXT NOT NULL DEFAULT ''`},
{"gwf_organization_invitations", "team_ids_json", `BLOB 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) exists, columnErr := sqliteColumnExists(ctx, tx, migration.table, migration.column)
if columnErr != nil { 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 { if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(9,?)`, time.Now().UTC().Unix()); err != nil {
return err return 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() return tx.Commit()
} }
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn
import (
"crypto/ecdh"
"crypto/rand"
"errors"
"testing"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
)
func TestEnforceCredentialAlgorithmUsesVerifiedCOSEKey(t *testing.T) {
privateKey, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
publicKey := privateKey.PublicKey().Bytes()
encoded, err := webauthncbor.Marshal(map[int64]any{
1: int64(webauthncose.EllipticKey),
3: int64(webauthncose.AlgES256),
-1: int64(webauthncose.P256),
-2: publicKey[1:33],
-3: publicKey[33:65],
})
if err != nil {
t.Fatal(err)
}
credential := &wa.Credential{
PublicKey: encoded,
// This value is absent when a standards-compliant client serializes the
// mandatory attestation object without optional response conveniences.
Attestation: wa.CredentialAttestation{PublicKeyAlgorithm: 0},
}
if err = enforceCredentialAlgorithm(credential); err != nil {
t.Fatalf("verified ES256 COSE key rejected when convenience value was absent: %v", err)
}
}
func TestEnforceCredentialAlgorithmRejectsOtherOrInvalidKeys(t *testing.T) {
rsaKey, err := webauthncbor.Marshal(map[int64]any{
1: int64(webauthncose.RSAKey),
3: int64(webauthncose.AlgRS256),
-1: []byte{0xff},
-2: []byte{0x01, 0x00, 0x01},
})
if err != nil {
t.Fatal(err)
}
for name, credential := range map[string]*wa.Credential{
"nil": nil,
"malformed": {PublicKey: []byte("not-cose")},
"rsa": {PublicKey: rsaKey},
} {
t.Run(name, func(t *testing.T) {
if err := enforceCredentialAlgorithm(credential); !errors.Is(err, ErrUnsupportedCredential) {
t.Fatalf("error=%v", err)
}
})
}
}
+21 -1
View File
@@ -375,7 +375,7 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
if err != nil { if err != nil {
return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err) 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 return Credential{}, ErrUnsupportedCredential
} }
encoded, err := json.Marshal(verified) encoded, err := json.Marshal(verified)
@@ -407,6 +407,26 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
return record, nil 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) { func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) {
challenge, err := service.randomBytes(32) challenge, err := service.randomBytes(32)
if err != nil { if err != nil {
+118
View File
@@ -0,0 +1,118 @@
// 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.
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"`
}
+69
View File
@@ -0,0 +1,69 @@
// 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 TestTaxonomyAndTermText(t *testing.T) {
tax := Taxonomy{ID: "categories", Slug: "categories", Name: "Categories", Revision: 1, Active: true}
if tax.Validate() != nil {
t.Fatal("valid taxonomy")
}
for _, name := range []string{"", " bad", "bad\nname", string([]byte{255}), strings.Repeat("a", 121)} {
v := tax
v.Name = name
if v.Validate() == nil {
t.Fatal("accepted invalid name")
}
}
term := Term{ID: "go", TaxonomyID: tax.ID, Slug: "go", Name: "Go", Revision: 1, Active: true}
if term.Validate() != nil {
t.Fatal("valid term")
}
term.Slug = "../go"
if term.Validate() == nil {
t.Fatal("unsafe slug")
}
}
func FuzzAssociations(f *testing.F) {
f.Add(`{"terms":["go"],"links":[{"kind":"news","id":"launch"}]}`)
f.Fuzz(func(t *testing.T, raw string) {
if len(raw) > 20000 {
return
}
var value Associations
if json.Unmarshal([]byte(raw), &value) != nil {
return
}
if value.Validate(Reference{Kind: "project", ID: "one"}) == nil {
b, e := json.Marshal(value)
if e != nil {
t.Fatal(e)
}
var again Associations
if json.Unmarshal(b, &again) != nil || again.Validate(Reference{Kind: "project", ID: "one"}) != nil {
t.Fatal("round trip")
}
}
})
}
+168
View File
@@ -0,0 +1,168 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite
import (
"context"
"database/sql"
"encoding/json"
"gamertan.com/web/cms"
)
// PutRevision appends once. Unknown terms are rejected; retired terms remain
// usable when copying historical revisions. Editors decide whether to admit new
// retired-term assignments. Link targets are validated by the application: they
// can live in a different content/catalog store. A link grants no authority.
func PutRevision(ctx context.Context, tx *sql.Tx, scope string, ref cms.Reference, revision int64, a cms.Associations) error {
if !cms.ValidID(scope) || revision < 1 || a.Validate(ref) != nil {
return cms.ErrInvalid
}
for _, id := range a.Terms {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_terms WHERE scope=? AND id=?`, scope, id).Scan(&count); err != nil {
return err
}
if count != 1 {
return cms.ErrNotFound
}
}
b, err := json.Marshal(a)
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_resources(scope,kind,id) VALUES(?,?,?) ON CONFLICT DO NOTHING`, scope, ref.Kind, ref.ID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_cms_associations(scope,kind,id,revision,document) VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, ref.Kind, ref.ID, revision, string(b))
if err = oneRow(result, err); err != nil {
return err
}
for _, id := range a.Terms {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_memberships(scope,kind,id,revision,term_id) VALUES(?,?,?,?,?)`, scope, ref.Kind, ref.ID, revision, id); err != nil {
return err
}
}
for _, target := range a.Links {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_links(scope,kind,id,revision,target_kind,target_id) VALUES(?,?,?,?,?,?)`, scope, ref.Kind, ref.ID, revision, target.Kind, target.ID); err != nil {
return err
}
}
return nil
}
// SetPublished selects an exact revision or zero to unpublish. Call inside the
// same transaction as the application's publication transition and audit. Draft
// saves do not call this function. Product availability is also checked by the
// consuming application; publication is not entitlement or payment authority.
func SetPublished(ctx context.Context, tx *sql.Tx, scope string, ref cms.Reference, revision int64) error {
if !cms.ValidID(scope) || ref.Validate() != nil || revision < 0 {
return cms.ErrInvalid
}
if revision > 0 {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=? AND revision=?`, scope, ref.Kind, ref.ID, revision).Scan(&count); err != nil {
return err
}
if count != 1 {
return cms.ErrNotFound
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_cms_resources SET published_revision=? WHERE scope=? AND kind=? AND id=?`, revision, scope, ref.Kind, ref.ID)
return oneRow(result, err)
}
func (r *Reader) Revision(ctx context.Context, ref cms.Reference, revision int64) (cms.Associations, error) {
if ref.Validate() != nil || revision < 1 {
return cms.Associations{}, cms.ErrInvalid
}
var raw string
err := r.db.QueryRowContext(ctx, `SELECT document FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=? AND revision=?`, r.scope, ref.Kind, ref.ID, revision).Scan(&raw)
if err != nil {
return cms.Associations{}, notFound(err)
}
var a cms.Associations
if len(raw) > 16384 || json.Unmarshal([]byte(raw), &a) != nil || a.Validate(ref) != nil {
return cms.Associations{}, cms.ErrInvalid
}
return a, nil
}
func (r *Reader) LatestRevision(ctx context.Context, ref cms.Reference) (int64, error) {
if ref.Validate() != nil {
return 0, cms.ErrInvalid
}
var revision sql.NullInt64
err := r.db.QueryRowContext(ctx, `SELECT MAX(revision) FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=?`, r.scope, ref.Kind, ref.ID).Scan(&revision)
if err != nil {
return 0, err
}
if !revision.Valid {
return 0, cms.ErrNotFound
}
return revision.Int64, nil
}
func (r *Reader) PublishedRevision(ctx context.Context, ref cms.Reference) (int64, error) {
if ref.Validate() != nil {
return 0, cms.ErrInvalid
}
var revision int64
err := r.db.QueryRowContext(ctx, `SELECT published_revision FROM gwf_cms_resources WHERE scope=? AND kind=? AND id=? AND published_revision>0`, r.scope, ref.Kind, ref.ID).Scan(&revision)
return revision, notFound(err)
}
// Members returns published members of an active term and taxonomy. Pagination
// happens after publication filtering, with stable kind/ID cursors.
func (r *Reader) Members(ctx context.Context, termID string, after *cms.Reference, limit int) (cms.Page, error) {
if !cms.ValidID(termID) {
return cms.Page{}, cms.ErrInvalid
}
query := `SELECT DISTINCT r.kind,r.id,r.published_revision FROM gwf_cms_memberships m JOIN gwf_cms_resources r ON r.scope=m.scope AND r.kind=m.kind AND r.id=m.id AND r.published_revision=m.revision JOIN gwf_cms_terms t ON t.scope=m.scope AND t.id=m.term_id JOIN gwf_cms_taxonomies x ON x.scope=t.scope AND x.id=t.taxonomy_id WHERE m.scope=? AND m.term_id=? AND r.published_revision>0 AND t.active=1 AND x.active=1`
return r.page(ctx, query, []any{r.scope, termID}, after, limit)
}
// Related returns both directions of explicit relationships between published
// revisions. Shared taxonomy membership alone does not assert a relationship.
func (r *Reader) Related(ctx context.Context, ref cms.Reference, after *cms.Reference, limit int) (cms.Page, error) {
if ref.Validate() != nil {
return cms.Page{}, cms.ErrInvalid
}
query := `WITH edges AS (
SELECT l.target_kind AS kind,l.target_id AS id FROM gwf_cms_links l JOIN gwf_cms_resources s ON s.scope=l.scope AND s.kind=l.kind AND s.id=l.id AND s.published_revision=l.revision WHERE l.scope=? AND l.kind=? AND l.id=? AND s.published_revision>0
UNION
SELECT l.kind,l.id FROM gwf_cms_links l JOIN gwf_cms_resources s ON s.scope=l.scope AND s.kind=l.kind AND s.id=l.id AND s.published_revision=l.revision WHERE l.scope=? AND l.target_kind=? AND l.target_id=? AND s.published_revision>0
) SELECT r.kind,r.id,r.published_revision FROM edges e JOIN gwf_cms_resources r ON r.kind=e.kind AND r.id=e.id WHERE r.scope=? AND r.published_revision>0 AND NOT(r.kind=? AND r.id=?) AND EXISTS(SELECT 1 FROM gwf_cms_resources origin WHERE origin.scope=? AND origin.kind=? AND origin.id=? AND origin.published_revision>0)`
return r.page(ctx, query, []any{r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID}, after, limit)
}
func (r *Reader) page(ctx context.Context, query string, args []any, after *cms.Reference, limit int) (cms.Page, error) {
if limit < 1 || limit > 100 || (after != nil && after.Validate() != nil) {
return cms.Page{}, cms.ErrInvalid
}
if after != nil {
query += ` AND (r.kind>? OR (r.kind=? AND r.id>?))`
args = append(args, after.Kind, after.Kind, after.ID)
}
query += ` ORDER BY r.kind,r.id LIMIT ?`
args = append(args, limit+1)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return cms.Page{}, err
}
defer rows.Close()
p := cms.Page{Items: []cms.ResourceVersion{}}
for rows.Next() {
var v cms.ResourceVersion
if err := rows.Scan(&v.Kind, &v.ID, &v.Revision); err != nil {
return cms.Page{}, err
}
p.Items = append(p.Items, v)
}
if err := rows.Err(); err != nil {
return cms.Page{}, err
}
if len(p.Items) > limit {
p.Items = p.Items[:limit]
ref := p.Items[limit-1].Reference
p.Next = &ref
}
return p, nil
}
+61
View File
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite_test
import (
"context"
"database/sql"
"fmt"
"gamertan.com/web/cms"
"gamertan.com/web/cmssqlite"
_ "modernc.org/sqlite"
)
func Example() {
ctx := context.Background()
db, err := sql.Open("sqlite", ":memory:?_pragma=foreign_keys(1)")
if err != nil {
panic(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
defer tx.Rollback()
if err = cmssqlite.CreateSchema(ctx, tx); err != nil {
panic(err)
}
const scope = "my-site"
if err = cmssqlite.PutTaxonomy(ctx, tx, scope, cms.Taxonomy{ID: "topics", Slug: "topics", Name: "Topics", Revision: 1, Active: true}, 0); err != nil {
panic(err)
}
if err = cmssqlite.PutTerm(ctx, tx, scope, cms.Term{ID: "go", TaxonomyID: "topics", Slug: "go", Name: "Go", Revision: 1, Active: true}, 0); err != nil {
panic(err)
}
ref := cms.Reference{Kind: "article", ID: "first-post"}
// The application authorizes the writer and stores its content/audit in this
// same transaction. Only publication advances the public association pointer.
if err = cmssqlite.PutRevision(ctx, tx, scope, ref, 1, cms.Associations{Terms: []string{"go"}}); err != nil {
panic(err)
}
if err = cmssqlite.SetPublished(ctx, tx, scope, ref, 1); err != nil {
panic(err)
}
if err = tx.Commit(); err != nil {
panic(err)
}
reader, err := cmssqlite.New(db, scope)
if err != nil {
panic(err)
}
page, err := reader.Members(ctx, "go", nil, 20)
if err != nil {
panic(err)
}
for _, item := range page.Items {
fmt.Println(item.Kind, item.ID, item.Revision)
}
// Output: article first-post 1
}
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: MPL-2.0
// Package cmssqlite stores cms values in an application's SQLite transaction.
// Schema installation is explicit. Callers own database opening, migration
// versions, authorization and audits. Every mutation must use a caller-owned
// transaction, committing its domain change and audit together; never use a
// pooled *sql.DB for multi-statement writes. Namespaces isolate application data.
package cmssqlite
import (
"context"
"database/sql"
"gamertan.com/web/cms"
)
type Queryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
// Reader may use a database or read transaction. Writers below require *sql.Tx.
type Reader struct {
db Queryer
scope string
}
func New(db Queryer, scope string) (*Reader, error) {
if db == nil || !cms.ValidID(scope) {
return nil, cms.ErrInvalid
}
return &Reader{db: db, scope: scope}, nil
}
// CreateSchema must be called from the application's explicit migration.
// It never changes an existing publishing schema or starts a transaction.
func CreateSchema(ctx context.Context, tx *sql.Tx) error {
for _, statement := range []string{
`CREATE TABLE IF NOT EXISTS gwf_cms_taxonomies (scope TEXT NOT NULL,id TEXT NOT NULL,slug TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),active INTEGER NOT NULL CHECK(active IN (0,1)),PRIMARY KEY(scope,id),UNIQUE(scope,slug))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_terms (scope TEXT NOT NULL,id TEXT NOT NULL,taxonomy_id TEXT NOT NULL,slug TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),active INTEGER NOT NULL CHECK(active IN (0,1)),PRIMARY KEY(scope,id),UNIQUE(scope,taxonomy_id,slug),FOREIGN KEY(scope,taxonomy_id) REFERENCES gwf_cms_taxonomies(scope,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_term_slugs (scope TEXT NOT NULL,taxonomy_id TEXT NOT NULL,slug TEXT NOT NULL,term_id TEXT NOT NULL,PRIMARY KEY(scope,taxonomy_id,slug),FOREIGN KEY(scope,term_id) REFERENCES gwf_cms_terms(scope,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_resources (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,published_revision INTEGER NOT NULL DEFAULT 0 CHECK(published_revision>=0),PRIMARY KEY(scope,kind,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_associations (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),document TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision),FOREIGN KEY(scope,kind,id) REFERENCES gwf_cms_resources(scope,kind,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_memberships (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL,term_id TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision,term_id),FOREIGN KEY(scope,kind,id,revision) REFERENCES gwf_cms_associations(scope,kind,id,revision),FOREIGN KEY(scope,term_id) REFERENCES gwf_cms_terms(scope,id))`,
`CREATE INDEX IF NOT EXISTS gwf_cms_memberships_term ON gwf_cms_memberships(scope,term_id,kind,id,revision)`,
`CREATE TABLE IF NOT EXISTS gwf_cms_links (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL,target_kind TEXT NOT NULL,target_id TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision,target_kind,target_id),FOREIGN KEY(scope,kind,id,revision) REFERENCES gwf_cms_associations(scope,kind,id,revision))`,
`CREATE INDEX IF NOT EXISTS gwf_cms_links_target ON gwf_cms_links(scope,target_kind,target_id,kind,id,revision)`,
} {
if _, err := tx.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
+214
View File
@@ -0,0 +1,214 @@
// 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 TestTermRenameRetirementAndScope(t *testing.T) {
db, r := fixture(t)
ctx := context.Background()
ref := cms.Reference{Kind: "writing", ID: "essay"}
save(t, db, ref, 1, cms.Associations{Terms: []string{"go"}}, true)
term, e := r.Term(ctx, "go")
if e != nil {
t.Fatal(e)
}
term.Slug = "golang"
term.Name = "Go language"
term.Revision = 2
mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 1) })
alias, e := r.TermBySlug(ctx, "topics", "go")
if e != nil || alias.ID != term.ID || alias.Slug != "golang" {
t.Fatal("alias", e)
}
stolen := cms.Term{ID: "stolen", TaxonomyID: "topics", Slug: "go", Name: "Other", Revision: 1, Active: true}
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", stolen, 0) })
duplicate := term
duplicate.Revision = 1
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", duplicate, 0) })
other, _ := New(db, "other")
if _, e = other.Term(ctx, "go"); !errors.Is(e, cms.ErrNotFound) {
t.Fatal("cross scope term", e)
}
mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error {
return PutRevision(ctx, tx, "other", ref, 1, cms.Associations{Terms: []string{"go"}})
})
p, e := other.Members(ctx, "go", nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("scope leak", e)
}
p, e = r.Members(ctx, "go", nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatal("membership lost on rename", e)
}
term.Active = false
term.Revision = 3
mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 2) })
p, e = r.Members(ctx, "go", nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("retired term discovery", e)
}
old, e := r.Revision(ctx, ref, 1)
if e != nil || len(old.Terms) != 1 {
t.Fatal("retirement rewrote history", e)
}
save(t, db, ref, 2, old, true)
}
func TestPaginationFiltersDraftsBeforeLimitAndRollback(t *testing.T) {
db, r := fixture(t)
ctx := context.Background()
for i := 0; i < 35; i++ {
save(t, db, cms.Reference{Kind: "news", ID: fmt.Sprintf("news-%02d", i)}, 1, cms.Associations{Terms: []string{"go"}}, i >= 30)
}
var after *cms.Reference
var ids []string
for {
p, e := r.Members(ctx, "go", after, 2)
if e != nil {
t.Fatal(e)
}
for _, v := range p.Items {
ids = append(ids, v.ID)
}
if p.Next == nil {
break
}
after = p.Next
}
if len(ids) != 5 || ids[0] != "news-30" || ids[4] != "news-34" {
t.Fatal(ids)
}
tx, e := db.BeginTx(ctx, nil)
if e != nil {
t.Fatal(e)
}
ref := cms.Reference{Kind: "project", ID: "rollback"}
if e = PutRevision(ctx, tx, "merchant", ref, 1, cms.Associations{}); e != nil {
t.Fatal(e)
}
if e = SetPublished(ctx, tx, "merchant", ref, 1); e != nil {
t.Fatal(e)
}
if e = tx.Rollback(); e != nil {
t.Fatal(e)
}
if _, e = r.PublishedRevision(ctx, ref); !errors.Is(e, cms.ErrNotFound) {
t.Fatal("partial transaction", e)
}
}
+159
View File
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite
import (
"context"
"database/sql"
"errors"
"gamertan.com/web/cms"
)
func notFound(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return cms.ErrNotFound
}
return err
}
func (r *Reader) Taxonomy(ctx context.Context, id string) (cms.Taxonomy, error) {
var v cms.Taxonomy
err := r.db.QueryRowContext(ctx, `SELECT id,slug,name,description,revision,active FROM gwf_cms_taxonomies WHERE scope=? AND id=?`, r.scope, id).Scan(&v.ID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active)
return v, notFound(err)
}
func (r *Reader) Taxonomies(ctx context.Context) ([]cms.Taxonomy, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id,slug,name,description,revision,active FROM gwf_cms_taxonomies WHERE scope=? ORDER BY slug LIMIT 101`, r.scope)
if err != nil {
return nil, err
}
defer rows.Close()
values := []cms.Taxonomy{}
for rows.Next() {
var v cms.Taxonomy
if err := rows.Scan(&v.ID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active); err != nil {
return nil, err
}
values = append(values, v)
}
if len(values) > 100 {
return nil, cms.ErrInvalid
}
return values, rows.Err()
}
func PutTaxonomy(ctx context.Context, tx *sql.Tx, scope string, v cms.Taxonomy, expected int64) error {
if !cms.ValidID(scope) || v.Validate() != nil || expected < 0 || v.Revision != expected+1 {
return cms.ErrInvalid
}
var result sql.Result
var err error
if expected == 0 {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_taxonomies WHERE scope=?`, scope).Scan(&count); err != nil {
return err
}
if count >= 100 {
return cms.ErrInvalid
}
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_taxonomies(scope,id,slug,name,description,revision,active) VALUES(?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, v.ID, v.Slug, v.Name, v.Description, v.Revision, v.Active)
} else {
result, err = tx.ExecContext(ctx, `UPDATE gwf_cms_taxonomies SET name=?,description=?,revision=?,active=? WHERE scope=? AND id=? AND revision=? AND slug=?`, v.Name, v.Description, v.Revision, v.Active, scope, v.ID, expected, v.Slug)
}
return oneRow(result, err)
}
func oneRow(result sql.Result, err error) error {
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return cms.ErrConflict
}
return nil
}
func (r *Reader) Term(ctx context.Context, id string) (cms.Term, error) {
var v cms.Term
err := r.db.QueryRowContext(ctx, `SELECT id,taxonomy_id,slug,name,description,revision,active FROM gwf_cms_terms WHERE scope=? AND id=?`, r.scope, id).Scan(&v.ID, &v.TaxonomyID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active)
return v, notFound(err)
}
// Terms pages by immutable ID, including retired values for editors. The caller
// filters public availability using both taxonomy and term Active fields.
func (r *Reader) Terms(ctx context.Context, taxonomyID, after string, limit int) ([]cms.Term, error) {
if !cms.ValidID(taxonomyID) || (after != "" && !cms.ValidID(after)) || limit < 1 || limit > 200 {
return nil, cms.ErrInvalid
}
rows, err := r.db.QueryContext(ctx, `SELECT id,taxonomy_id,slug,name,description,revision,active FROM gwf_cms_terms WHERE scope=? AND taxonomy_id=? AND id>? ORDER BY id LIMIT ?`, r.scope, taxonomyID, after, limit)
if err != nil {
return nil, err
}
defer rows.Close()
values := []cms.Term{}
for rows.Next() {
var v cms.Term
if err := rows.Scan(&v.ID, &v.TaxonomyID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active); err != nil {
return nil, err
}
values = append(values, v)
}
return values, rows.Err()
}
// TermBySlug resolves old term slugs to the current record. The caller redirects
// to v.Slug after checking visibility; no private/retired term is published here.
func (r *Reader) TermBySlug(ctx context.Context, taxonomyID, slug string) (cms.Term, error) {
var id string
err := r.db.QueryRowContext(ctx, `SELECT id FROM gwf_cms_terms WHERE scope=? AND taxonomy_id=? AND slug=? UNION SELECT term_id FROM gwf_cms_term_slugs WHERE scope=? AND taxonomy_id=? AND slug=? LIMIT 1`, r.scope, taxonomyID, slug, r.scope, taxonomyID, slug).Scan(&id)
if err != nil {
return cms.Term{}, notFound(err)
}
return r.Term(ctx, id)
}
func PutTerm(ctx context.Context, tx *sql.Tx, scope string, v cms.Term, expected int64) error {
if !cms.ValidID(scope) || v.Validate() != nil || expected < 0 || v.Revision != expected+1 {
return cms.ErrInvalid
}
r, _ := New(tx, scope)
tax, err := r.Taxonomy(ctx, v.TaxonomyID)
if err != nil {
return err
}
if !tax.Active && v.Active {
return cms.ErrInvalid
}
var old cms.Term
if expected > 0 {
old, err = r.Term(ctx, v.ID)
if err != nil {
return err
}
if old.Revision != expected || old.TaxonomyID != v.TaxonomyID {
return cms.ErrConflict
}
}
occupied, err := r.TermBySlug(ctx, v.TaxonomyID, v.Slug)
if err == nil && occupied.ID != v.ID {
return cms.ErrConflict
}
if err != nil && !errors.Is(err, cms.ErrNotFound) {
return err
}
var result sql.Result
if expected == 0 {
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_terms(scope,id,taxonomy_id,slug,name,description,revision,active) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, v.ID, v.TaxonomyID, v.Slug, v.Name, v.Description, v.Revision, v.Active)
} else {
result, err = tx.ExecContext(ctx, `UPDATE gwf_cms_terms SET slug=?,name=?,description=?,revision=?,active=? WHERE scope=? AND id=? AND revision=?`, v.Slug, v.Name, v.Description, v.Revision, v.Active, scope, v.ID, expected)
}
if err = oneRow(result, err); err != nil {
return err
}
if expected > 0 && old.Slug != v.Slug {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_cms_term_slugs WHERE scope=? AND taxonomy_id=? AND slug=? AND term_id=?`, scope, v.TaxonomyID, v.Slug, v.ID); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_term_slugs(scope,taxonomy_id,slug,term_id) VALUES(?,?,?,?)`, scope, v.TaxonomyID, old.Slug, v.ID)
}
return err
}
+4
View File
@@ -28,6 +28,8 @@
// optional no-CGO SQLite adapter. // optional no-CGO SQLite adapter.
// - [organizations] and [access] model organizations, teams, invitations, // - [organizations] and [access] model organizations, teams, invitations,
// scoped roles, and audited temporary access. // 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. // - [abuse] applies application-classified request-abuse decisions.
// - [analytics] creates bounded, disposable projections from requestlog // - [analytics] creates bounded, disposable projections from requestlog
// records without becoming a telemetry service. // records without becoming a telemetry service.
@@ -58,6 +60,8 @@
// [authhttp]: https://pkg.go.dev/gamertan.com/web/authhttp // [authhttp]: https://pkg.go.dev/gamertan.com/web/authhttp
// [authsqlite]: https://pkg.go.dev/gamertan.com/web/authsqlite // [authsqlite]: https://pkg.go.dev/gamertan.com/web/authsqlite
// [authwebauthn]: https://pkg.go.dev/gamertan.com/web/authwebauthn // [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 // [organizations]: https://pkg.go.dev/gamertan.com/web/organizations
// [requestlog]: https://pkg.go.dev/gamertan.com/web/requestlog // [requestlog]: https://pkg.go.dev/gamertan.com/web/requestlog
// [requestmeta]: https://pkg.go.dev/gamertan.com/web/requestmeta // [requestmeta]: https://pkg.go.dev/gamertan.com/web/requestmeta
+23
View File
@@ -21,3 +21,26 @@ template. Its private evidence, persistent bans, account data, route policy,
operator exclusions, synchronization, and publishing workflow remain operator exclusions, synchronization, and publishing workflow remain
application-owned. Useful pressure from that migration may improve a general application-owned. Useful pressure from that migration may improve a general
interface, but it may not smuggle EQL-specific policy into this module. 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.
+74
View File
@@ -0,0 +1,74 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Classification without a page builder
`cms` defines small editorial values. `cmssqlite` stores them in caller-owned
SQLite transactions. Neither package owns your content, router, templates,
permissions or billing model. Use them alongside typed Go records and coded
templates, not as a database-defined application builder.
## Two distinct relationships
- A **taxonomy** names a flat vocabulary, such as Categories or Topics. A **term**
has a stable ID, editable name/description and an address. Renaming its address
preserves aliases; retiring it hides discovery without rewriting old revisions.
- An **explicit link** joins two `{kind, id}` references. `Related` reads that
single edge in either direction. Sharing a term alone does not assert a link.
References contain no titles, URLs, application data or authorization. Resolve
each public result through the owning repository using its current published
revision and availability. Never render the latest draft merely because an older
revision is published. Products may have additional availability/approval rules;
editorial links do not bypass them or change prices, entitlements or purchases.
## Transactions and publication
Call `CreateSchema(ctx, tx)` during an explicit application migration. It creates
the `gwf_cms_*` tables, independently of the authentication adapter's schema.
There is no automatic migration, connection, worker or request middleware.
Enable foreign keys on every connection and use the application's existing
SQLite write discipline. Keep backups and schema compatibility in that owner.
Every reader/writer takes a validated namespace. A namespace separates data; it
does not authorize the caller. Check permissions before reads and inside the
application's mutation boundary where concurrent revocation matters.
Within the same transaction as your content revision and audit:
1. `PutRevision` stores the exact revision's immutable term/link selections.
2. For publication, `SetPublished` points at that revision. Zero unpublishes.
3. Commit content, associations, publication and audit together.
Saving a draft does not advance publication. Restore by copying the selected
historical association document into a new revision, then publish separately.
Terms must exist in the namespace; retired terms remain valid historical values.
Applications decide which retired selections may be retained in new edits.
External targets can be indexed with an empty published snapshot, but their
owning module still determines whether a public link is available.
Taxonomy/term writes use expected revisions (zero for creation). Keep immutable
IDs across name changes. Taxonomy addresses are fixed after creation; term
addresses retain redirect history. A collision or stale revision returns
`cms.ErrConflict`, not a successful overwrite. Transactions must be rolled back
after any mutation error, including a later application audit failure.
## Bounds and discovery
- Each snapshot accepts at most 24 distinct terms and 16 distinct links, without
self-links. Validation rejects invalid IDs, control characters and duplicate
selections. Text fields have explicit byte bounds; names are not HTML.
- A namespace has at most 100 taxonomies. `Terms` pages by stable term ID, at
most 200 results per request. Applications should choose their own overall
editor limits and search UI rather than loading an unbounded catalog.
- `Members` and `Related` return at most 100 published references per page,
ordered by kind/ID. Pass `Next` for the following page. Publication filtering
happens before pagination; never use a mutable title as a cursor.
- Owning-module visibility can filter further. Continue fetching bounded index
pages to fill a visible page and construct a cursor from the last visible
item; do not expose private titles or identifiers through error messages.
The package tests use real SQLite transactions, including WAL, race execution,
scope isolation, delayed publication, reverse discovery, aliases, retirement,
pagination and rollback. Consumer tests still need to prove actual HTTP/API
permissions, public visibility, escaping, editor usability and application data
preservation. These packages do not claim to provide an entire CMS.
+69 -2
View File
@@ -8,9 +8,68 @@ application concern belongs in the shared module.
## Gamertan accounts and commerce ## 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 - The account email remains required and unique. Gamertan uses normalized
email as the canonical login identifier and keeps username as a stable public email as the canonical login identifier; the immutable user ID, not the editable
identity. Until a mail package exists, the application must not describe an username, owns account relationships. Until a mail package exists, it must not describe an
address as verified merely because it was entered during registration. address as verified merely because it was entered during registration.
- Password authentication is sufficient for an ordinary customer base - Password authentication is sufficient for an ordinary customer base
session. Privileged application actions use an exact operation binding with 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 role into invitation mutations and rechecks a current active direct Owner
after acquiring the SQLite write lock. The application still owns fresh after acquiring the SQLite write lock. The application still owns fresh
authentication, recipient delivery, and the one-time secret presentation. 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.
+1 -1
View File
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
module checksum: module checksum:
```bash ```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.20 go get gamertan.com/web/requestmeta@v0.1.0-preview.27
go mod verify go mod verify
``` ```
+1 -1
View File
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
and request the containing module at an exact version: and request the containing module at an exact version:
```bash ```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.20 go get gamertan.com/web/requestmeta@v0.1.0-preview.25
``` ```
Only imported packages are compiled and linked. The packages nevertheless Only imported packages are compiled and linked. The packages nevertheless
+124 -7
View File
@@ -8,17 +8,103 @@ environments; environments own application services. Teams are optional groups
of active organization members. of active organization members.
`organizations.Service` creates those resources and issues digest-backed, `organizations.Service` creates those resources and issues digest-backed,
expiring, single-use invitations. An invitation may carry one direct role and expiring, single-use invitations. An invitation may carry up to sixteen direct
up to sixteen reviewed team memberships. Acceptance verifies that the roles and sixteen reviewed team memberships. Acceptance verifies that the
authenticated user's normalized email matches and applies the membership, authenticated user's normalized email matches and applies the membership,
role, teams, consumption marker, and audit event in one transaction. roles, teams, consumption marker, and audit event in one transaction. The
When `OwnerRole` is configured, creating or revoking an invitation carrying recipient and issuing member must remain active, fully registered users of an
that role additionally requires a current active direct owner inside the same active organization; a suspended recipient cannot use an invitation as implicit
SQLite transaction. A broad access-management permission may administer reactivation. Existing members use the membership editor, not another invitation,
ordinary invitations but cannot create or cancel owner access. 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 Applications own invitation pages, email or out-of-band delivery, active-source
checks before archival, and account recovery. 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 Organizations and teams use optimistic revisions and reversible
`active`/`archived` states. Archived objects keep their history but contribute `active`/`archived` states. Archived objects keep their history but contribute
no effective authority. Memberships may be suspended, reactivated, or removed; 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 authorize the administrator and bind any required fresh passkey assertion to
the organization, target user, target role, and expected IDs. 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: `access.Service` evaluates a permission against a complete resource scope:
```go ```go
+4
View File
@@ -19,6 +19,10 @@ authorization decisions, session cookie, HTML, and local recovery command.
- Request no attestation conveyance. - Request no attestation conveyance.
- Permit ES256 only until another algorithm has explicit interoperability and - Permit ES256 only until another algorithm has explicit interoperability and
security evidence. 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, - Store random challenges and verifier session data only behind opaque,
single-use ceremony tokens. single-use ceremony tokens.
- Treat clone warnings as audit signals rather than automatic lockout for - Treat clone warnings as audit signals rather than automatic lockout for
+5
View File
@@ -9,3 +9,8 @@ the exact same exported tree as a read-only discovery mirror.
The exporter includes no branches, reflogs, private operational evidence, The exporter includes no branches, reflogs, private operational evidence,
credentials, databases, logs, or development-only files. Public Gitea issues credentials, databases, logs, or development-only files. Public Gitea issues
and pull requests are the contribution venue. 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.
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
)
var ErrDirectoryQuery = errors.New("organizations: invalid directory query")
// DirectoryQuery searches all organizations independently of membership.
// Search is literal text. AfterID is an exclusive stable-ID cursor. Limit
// defaults to 50 and may not exceed 200.
type DirectoryQuery struct {
Search, AfterID string
Limit int
}
type DirectoryPage struct {
Organizations []Organization
NextID string
}
// DirectoryRepository is an optional administrative read capability. The caller
// MUST authorize instance-wide organization access. Personal and archived records
// are included; no membership is granted and no invitations or secrets are read.
// The application classifies its configured merchant organization. Pagination is
// a current view, not a snapshot across requests.
type DirectoryRepository interface {
OrganizationDirectory(context.Context, DirectoryQuery) (DirectoryPage, error)
}
+115 -30
View File
@@ -14,6 +14,7 @@ import (
"fmt" "fmt"
"io" "io"
"regexp" "regexp"
"slices"
"strings" "strings"
"time" "time"
) )
@@ -28,6 +29,7 @@ var (
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed") ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved") ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
ErrOwnerAuthority = errors.New("organizations: a current direct owner must manage owner access") 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}$`) slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`) idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
) )
@@ -76,11 +78,16 @@ type ApplicationService struct {
} }
type Invitation struct { type Invitation struct {
ID string ID string
Digest [32]byte Digest [32]byte
OrganizationID string OrganizationID string
Email, InvitedByUserID string Email, InvitedByUserID string
DirectRole 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 TeamIDs []string
CreatedAt, ExpiresAt, UsedAt, RevokedAt time.Time CreatedAt, ExpiresAt, UsedAt, RevokedAt time.Time
} }
@@ -128,13 +135,17 @@ type Options struct {
Random io.Reader Random io.Reader
Now func() time.Time Now func() time.Time
OwnerRole string 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 { type Service struct {
repository Repository repository Repository
random io.Reader random io.Reader
now func() time.Time now func() time.Time
ownerRole string ownerRole string
ownerManagedInvitations bool
} }
func New(repository Repository, options Options) (*Service, error) { func New(repository Repository, options Options) (*Service, error) {
@@ -147,31 +158,20 @@ func New(repository Repository, options Options) (*Service, error) {
if options.Now == nil { if options.Now == nil {
options.Now = time.Now 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 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 { type CreateOrganization struct {
Slug, Name, OwnerUserID string Slug, Name, OwnerUserID string
Personal bool Personal bool
RequestID string
} }
func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, error) { func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug)) organization, owner, audit, err := service.prepareOrganization(input)
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")
if err != nil { if err != nil {
return Organization{}, err return Organization{}, err
} }
@@ -181,6 +181,26 @@ func (service *Service) CreateOrganization(ctx context.Context, input CreateOrga
return organization, nil 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) { func (service *Service) CreatePersonalOrganization(ctx context.Context, userID, displayName string) (Organization, error) {
value := make([]byte, 6) value := make([]byte, 6)
if _, err := io.ReadFull(service.random, value); err != nil { 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 { type InviteWithAccess struct {
OrganizationID, Email, InvitedByUserID, DirectRole string OrganizationID, Email, InvitedByUserID, DirectRole string
DirectRoles []string
RequestID string
TeamIDs []string TeamIDs []string
Lifetime time.Duration 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 organizationID, email, invitedBy, lifetime := input.OrganizationID, input.Email, input.InvitedByUserID, input.Lifetime
email = strings.ToLower(strings.TrimSpace(email)) email = strings.ToLower(strings.TrimSpace(email))
input.DirectRole = strings.TrimSpace(input.DirectRole) 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") 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) id, err := token(service.random, 18)
if err != nil { if err != nil {
return "", Invitation{}, err return "", Invitation{}, err
@@ -310,11 +340,22 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
} }
now := service.now().UTC() 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)} 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 { if err != nil {
return "", Invitation{}, err 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 "", Invitation{}, err
} }
return raw, invitation, nil return raw, invitation, nil
@@ -334,6 +375,12 @@ func (service *Service) AcceptInvitation(ctx context.Context, rawToken, userID s
if err != nil { if err != nil {
return err 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) 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) { 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) 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) { 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") return Organization{}, errors.New("organizations: invalid organization update")
@@ -375,12 +430,20 @@ func (service *Service) UpdateOrganization(ctx context.Context, input UpdateOrga
if err != nil { if err != nil {
return Organization{}, err 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() 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") audit, err := service.auditWithRequest(input.ActorUserID, value.ID, "organization.update", "organization", value.ID, input.RequestID, "Organization details updated")
if err != nil { if err != nil {
return Organization{}, err 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 Organization{}, err
} }
return value, nil return value, nil
@@ -484,6 +547,10 @@ type MembershipStatusChange struct {
} }
func (service *Service) ChangeMembershipStatus(ctx context.Context, input MembershipStatusChange) error { 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) || if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) ||
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || (input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") ||
(input.Status != "active" && input.Status != "suspended") || input.Status == input.ExpectedStatus || (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 == "" { if service.ownerRole == "" {
return errors.New("organizations: owner role is required for membership lifecycle changes") 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) repository, ok := service.repository.(OptimisticMembershipRepository)
if !ok { if !requireOwner && !ok {
return ErrMembershipLifecycleUnsupported return ErrMembershipLifecycleUnsupported
} }
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership."+input.Status, "membership", input.UserID, input.RequestID, "Organization membership set to "+input.Status) 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 { if err != nil {
return err return err
} }
if requireOwner {
return ownedRepository.ChangeOwnedMembershipStatus(ctx, input, service.ownerRole, audit)
}
return repository.ChangeMembershipStatus(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 { 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) || if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) ||
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || !boundedOptional(input.RequestID, 128) { (input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || !boundedOptional(input.RequestID, 128) {
return errors.New("organizations: invalid membership removal") return errors.New("organizations: invalid membership removal")
@@ -532,14 +610,21 @@ func (service *Service) RemoveMembershipIfCurrent(ctx context.Context, input Mem
if service.ownerRole == "" { if service.ownerRole == "" {
return errors.New("organizations: owner role is required for membership lifecycle changes") 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) repository, ok := service.repository.(OptimisticMembershipRepository)
if !ok { if !requireOwner && !ok {
return ErrMembershipLifecycleUnsupported return ErrMembershipLifecycleUnsupported
} }
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership.remove", "membership", input.UserID, input.RequestID, "Organization membership removed") audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership.remove", "membership", input.UserID, input.RequestID, "Organization membership removed")
if err != nil { if err != nil {
return err return err
} }
if requireOwner {
return ownedRepository.RemoveOwnedMembershipIfCurrent(ctx, input, service.ownerRole, audit)
}
return repository.RemoveMembershipIfCurrent(ctx, input, service.ownerRole, audit) return repository.RemoveMembershipIfCurrent(ctx, input, service.ownerRole, audit)
} }
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
"gamertan.com/web/access"
)
// OwnedOrganization is one atomic creation command. Implementations must commit
// the organization, membership, direct owner binding, and both audits together.
type OwnedOrganization struct {
Organization Organization
Membership Membership
OwnerBinding access.Binding
OrganizationAudit AuditEvent
AccessAudit access.AuditEvent
}
// OwnedOrganizationRepository extends Repository without changing the legacy
// membership-only CreateOrganization contract. There is no non-atomic fallback.
type OwnedOrganizationRepository interface {
CreateOwnedOrganization(context.Context, OwnedOrganization) error
}
var ErrOwnedManagementUnsupported = errors.New("organizations: atomic owner-managed updates are unsupported")
// OwnerManagedRepository rechecks the configured direct owner in the same
// transaction as profile and membership writes. These explicit operations do
// not change legacy methods used by applications with delegated administrators.
// Implementations must also preserve optimistic state, last-owner protection,
// and audit atomicity. There is no preflight-only fallback.
type OwnerManagedRepository interface {
UpdateOwnedOrganization(context.Context, Organization, int64, string, AuditEvent) error
ChangeOwnedMembershipStatus(context.Context, MembershipStatusChange, string, AuditEvent) error
RemoveOwnedMembershipIfCurrent(context.Context, MembershipRemoval, string, AuditEvent) error
}
// UpdateOwnedOrganization changes a non-personal organization's name and slug.
// OwnerRole comes from trusted service configuration, not submitted form data.
func (service *Service) UpdateOwnedOrganization(ctx context.Context, input UpdateOrganization) (Organization, error) {
return service.updateOrganization(ctx, input, true)
}
// ChangeOwnedMembershipStatus requires a current owner even when the target
// member is not an owner. It preserves the last active owner.
func (service *Service) ChangeOwnedMembershipStatus(ctx context.Context, input MembershipStatusChange) error {
return service.changeMembershipStatus(ctx, input, true)
}
// RemoveOwnedMembershipIfCurrent removes only the displayed membership state,
// with current owner authority checked in the write transaction.
func (service *Service) RemoveOwnedMembershipIfCurrent(ctx context.Context, input MembershipRemoval) error {
return service.removeMembershipIfCurrent(ctx, input, true)
}
// CreateOwnedOrganization grants the configured OwnerRole to the initial owner
// inside the creation transaction. Applications authorize creation and choose
// OwnerRole when constructing the service, never from a submitted role name.
// The role must already be seeded in the repository.
func (service *Service) CreateOwnedOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
if service.ownerRole == "" {
return Organization{}, errors.New("organizations: owned creation requires a configured owner role")
}
repository, ok := service.repository.(OwnedOrganizationRepository)
if !ok {
return Organization{}, ErrOwnedCreationUnsupported
}
organization, membership, audit, err := service.prepareOrganization(input)
if err != nil {
return Organization{}, err
}
bindingID, err := token(service.random, 18)
if err != nil {
return Organization{}, err
}
accessAuditID, err := token(service.random, 18)
if err != nil {
return Organization{}, err
}
binding := access.Binding{
ID: bindingID, SubjectKind: access.User, SubjectID: input.OwnerUserID,
Role: service.ownerRole, Scope: access.Scope{OrganizationID: organization.ID},
GrantedBy: input.OwnerUserID, GrantedAt: organization.CreatedAt,
}
accessAudit := access.AuditEvent{
ID: accessAuditID, OrganizationID: organization.ID, ActorUserID: input.OwnerUserID,
Action: "access.binding.grant", ResourceType: "binding", ResourceID: bindingID,
RequestID: input.RequestID, Summary: "Initial organization owner granted",
CreatedAt: organization.CreatedAt,
}
if err = repository.CreateOwnedOrganization(ctx, OwnedOrganization{
Organization: organization, Membership: membership, OwnerBinding: binding,
OrganizationAudit: audit, AccessAudit: accessAudit,
}); err != nil {
return Organization{}, err
}
return organization, nil
}
+113
View File
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
"strings"
"testing"
"time"
"gamertan.com/web/access"
)
type ownedRepositoryStub struct {
repositoryStub
setup OwnedOrganization
calls int
err error
}
func (repository *ownedRepositoryStub) CreateOwnedOrganization(_ context.Context, setup OwnedOrganization) error {
repository.calls++
repository.setup = setup
return repository.err
}
func TestOwnedOrganizationUsesConfiguredRoleAndAtomicRepository(t *testing.T) {
now := time.Unix(1000, 0).UTC()
repository := &ownedRepositoryStub{}
service, err := New(repository, Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
input := CreateOrganization{Slug: " CLIENT-BUSINESS ", Name: " Client Business ", OwnerUserID: "customer-12345", RequestID: "request-creation"}
organization, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
setup := repository.setup
if repository.calls != 1 || repository.organization.ID != "" || setup.Organization != organization || organization.Slug != "client-business" || organization.Name != "Client Business" {
t.Fatalf("unexpected creation: %+v", setup)
}
if setup.Membership.UserID != input.OwnerUserID || setup.OwnerBinding.SubjectKind != access.User || setup.OwnerBinding.SubjectID != input.OwnerUserID || setup.OwnerBinding.Role != "customer.owner" || setup.OwnerBinding.Scope != (access.Scope{OrganizationID: organization.ID}) || setup.OwnerBinding.GrantedBy != input.OwnerUserID {
t.Fatalf("unexpected owner: %+v", setup)
}
if setup.OrganizationAudit.RequestID != input.RequestID || setup.AccessAudit.RequestID != input.RequestID || setup.AccessAudit.ResourceID != setup.OwnerBinding.ID || !setup.OwnerBinding.GrantedAt.Equal(now) {
t.Fatalf("unexpected audits: %+v", setup)
}
}
func TestOwnedOrganizationFailsWithoutAtomicSupport(t *testing.T) {
repository := &repositoryStub{}
service, _ := New(repository, Options{OwnerRole: "customer.owner"})
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345"})
if !errors.Is(err, ErrOwnedCreationUnsupported) || organization.ID != "" || repository.organization.ID != "" {
t.Fatalf("non-atomic fallback: organization=%+v err=%v", organization, err)
}
}
func TestOwnedOrganizationRejectsInvalidSetupBeforeStorage(t *testing.T) {
for _, test := range []struct {
name string
role string
request string
random string
}{
{name: "missing role", random: strings.Repeat("a", 200)},
{name: "bad request ID", role: "customer.owner", request: "request\nsecret", random: strings.Repeat("a", 200)},
{name: "random failure", role: "customer.owner", random: strings.Repeat("a", 40)},
} {
t.Run(test.name, func(t *testing.T) {
repository := &ownedRepositoryStub{}
service, err := New(repository, Options{OwnerRole: test.role, Random: strings.NewReader(test.random)})
if err != nil {
t.Fatal(err)
}
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345", RequestID: test.request})
if err == nil || organization.ID != "" || repository.calls != 0 {
t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err)
}
})
}
}
func TestOwnedOrganizationDoesNotReturnUncommittedIdentity(t *testing.T) {
want := errors.New("durability failure")
repository := &ownedRepositoryStub{err: want}
service, _ := New(repository, Options{OwnerRole: "customer.owner"})
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345"})
if !errors.Is(err, want) || organization.ID != "" || repository.calls != 1 {
t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err)
}
}
func TestOwnedManagementHasNoPreflightOnlyFallback(t *testing.T) {
service, err := New(&repositoryStub{}, Options{OwnerRole: "customer.owner"})
if err != nil {
t.Fatal(err)
}
_, err = service.UpdateOwnedOrganization(t.Context(), UpdateOrganization{ID: "organization-12345", Slug: "business", Name: "Business", ActorUserID: "customer-12345", ExpectedRevision: 1})
if !errors.Is(err, ErrOwnedManagementUnsupported) {
t.Fatalf("profile fallback: %v", err)
}
err = service.ChangeOwnedMembershipStatus(t.Context(), MembershipStatusChange{OrganizationID: "organization-12345", UserID: "member-12345", ActorUserID: "customer-12345", ExpectedStatus: "active", Status: "suspended"})
if !errors.Is(err, ErrOwnedManagementUnsupported) {
t.Fatalf("status fallback: %v", err)
}
err = service.RemoveOwnedMembershipIfCurrent(t.Context(), MembershipRemoval{OrganizationID: "organization-12345", UserID: "member-12345", ActorUserID: "customer-12345", ExpectedStatus: "active"})
if !errors.Is(err, ErrOwnedManagementUnsupported) {
t.Fatalf("removal fallback: %v", err)
}
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
"slices"
"time"
)
var ErrRoleInvitationUnsupported = errors.New("organizations: atomic role-set invitations are unsupported")
// RoleInvitationRepository must preserve the entire role set and its required
// owner authority, then commit acceptance, membership, grants and audit together.
type RoleInvitationRepository interface {
CreateInvitationWithRoles(context.Context, Invitation, string, AuditEvent) error
AcceptInvitationWithRoles(context.Context, [32]byte, string, string, time.Time, AuditEvent) error
}
// RoleNames returns a validated copy of the invitation's direct roles. The older
// DirectRole remains supported; supplying both forms is an error, not a union.
func (invitation Invitation) RoleNames() ([]string, error) {
if invitation.DirectRole != "" && len(invitation.DirectRoles) > 0 || len(invitation.DirectRoles) > 16 {
return nil, errors.New("organizations: invalid invitation roles")
}
roles := append([]string(nil), invitation.DirectRoles...)
if invitation.DirectRole != "" {
roles = append(roles, invitation.DirectRole)
}
slices.Sort(roles)
for i, role := range roles {
if !safeNamePattern.MatchString(role) || i > 0 && role == roles[i-1] {
return nil, errors.New("organizations: invalid invitation role")
}
}
return roles, nil
}
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"crypto/sha256"
"errors"
"slices"
"strings"
"testing"
"time"
)
type roleInvitationRepositoryStub struct {
repositoryStub
audit AuditEvent
ownerRole string
created int
}
func (r *roleInvitationRepositoryStub) CreateInvitationWithRoles(_ context.Context, invitation Invitation, ownerRole string, audit AuditEvent) error {
r.created++
r.invitation, r.audit, r.ownerRole = invitation, audit, ownerRole
return nil
}
func (r *roleInvitationRepositoryStub) AcceptInvitationWithRoles(_ context.Context, _ [32]byte, userID, ownerRole string, _ time.Time, audit AuditEvent) error {
r.acceptedUser, r.ownerRole, r.audit = userID, ownerRole, audit
return nil
}
func TestInvitationRoleNamesAreBoundedAndUnambiguous(t *testing.T) {
input := []string{"buyer", "billing"}
roles, err := (Invitation{DirectRoles: input}).RoleNames()
if err != nil || !slices.Equal(roles, []string{"billing", "buyer"}) || !slices.Equal(input, []string{"buyer", "billing"}) {
t.Fatalf("roles=%v input=%v err=%v", roles, input, err)
}
for _, invitation := range []Invitation{
{DirectRole: "owner", DirectRoles: []string{"buyer"}},
{DirectRoles: []string{"buyer", "buyer"}},
{DirectRoles: []string{"not a role"}},
{DirectRoles: make([]string, 17)},
} {
if _, err := invitation.RoleNames(); err == nil {
t.Fatalf("invalid roles accepted=%+v", invitation)
}
}
if roles, err = (Invitation{DirectRole: "buyer"}).RoleNames(); err != nil || !slices.Equal(roles, []string{"buyer"}) {
t.Fatalf("legacy=%v err=%v", roles, err)
}
}
func TestRoleInvitationServicePreservesOwnerRequirementAndRequest(t *testing.T) {
r := &roleInvitationRepositoryStub{}
service, err := New(r, Options{OwnerRole: "owner", OwnerManagedInvitations: true})
if err != nil {
t.Fatal(err)
}
input := InviteWithAccess{OrganizationID: "organization-123", InvitedByUserID: "owner-12345678", Email: " Member@example.test ", DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour, RequestID: "request-invite"}
raw, invitation, err := service.InviteWithAccess(t.Context(), input)
if err != nil {
t.Fatal(err)
}
if invitation.Digest != sha256.Sum256([]byte(raw)) || invitation.RequiredOwnerRole != "owner" || r.ownerRole != "owner" || r.audit.RequestID != input.RequestID || !slices.Equal(invitation.DirectRoles, []string{"billing", "buyer"}) || invitation.Email != "member@example.test" {
t.Fatalf("invitation or audit mismatch: %+v %+v", invitation, r.audit)
}
if !slices.Equal(input.DirectRoles, []string{"buyer", "billing"}) {
t.Fatal("caller roles mutated")
}
if err = service.AcceptInvitation(t.Context(), raw, "member-12345678"); err != nil || r.ownerRole != "owner" || r.acceptedUser != "member-12345678" {
t.Fatalf("accept=%v owner=%q user=%q", err, r.ownerRole, r.acceptedUser)
}
input.RequestID = strings.Repeat("x", 129)
if _, _, err = service.InviteWithAccess(t.Context(), input); err == nil || r.created != 1 {
t.Fatal("oversized request accepted")
}
if _, err = New(r, Options{OwnerManagedInvitations: true}); err == nil {
t.Fatal("owner-managed service without owner accepted")
}
}
func TestRoleInvitationHasNoPartialLegacyFallback(t *testing.T) {
for _, input := range []InviteWithAccess{
{DirectRoles: []string{"buyer", "billing"}}, {DirectRole: "owner"}, {},
} {
r := &repositoryStub{}
service, err := New(r, Options{OwnerRole: "owner", OwnerManagedInvitations: true})
if err != nil {
t.Fatal(err)
}
input.OrganizationID, input.InvitedByUserID, input.Email, input.Lifetime = "organization-123", "owner-12345678", "member@example.test", time.Hour
if _, _, err = service.InviteWithAccess(t.Context(), input); !errors.Is(err, ErrRoleInvitationUnsupported) || r.invitation.ID != "" {
t.Fatalf("legacy fallback=%v", err)
}
r.invitation = Invitation{OrganizationID: input.OrganizationID, ID: "invitation-1234", RequiredOwnerRole: "owner"}
if err = service.AcceptInvitation(t.Context(), strings.Repeat("a", 43), "member-12345678"); !errors.Is(err, ErrRoleInvitationUnsupported) || r.acceptedUser != "" {
t.Fatalf("legacy acceptance fallback=%v", err)
}
}
}
+27
View File
@@ -5,6 +5,7 @@
.gitea/workflows/verify.yml .gitea/workflows/verify.yml
.gitignore .gitignore
CHANGELOG.md CHANGELOG.md
AI_DISCLOSURE.md
CONTRIBUTING.md CONTRIBUTING.md
LICENSE LICENSE
LICENSES.md LICENSES.md
@@ -20,12 +21,17 @@ abuse/abuse_test.go
account/account.go account/account.go
access/access.go access/access.go
access/access_test.go access/access_test.go
access/role_sets.go
access/role_sets_test.go
analytics/analytics.go analytics/analytics.go
analytics/analytics_test.go analytics/analytics_test.go
analytics/fuzz_test.go analytics/fuzz_test.go
analytics/geo.go analytics/geo.go
auth/auth.go auth/auth.go
auth/context.go auth/context.go
auth/directory.go
auth/profile.go
auth/profile_test.go
auth/password.go auth/password.go
auth/password_test.go auth/password_test.go
authrecovery/recovery.go authrecovery/recovery.go
@@ -38,6 +44,10 @@ authhttp/passkey.go
authhttp/passkey_test.go authhttp/passkey_test.go
authsqlite/store.go authsqlite/store.go
authsqlite/store_test.go authsqlite/store_test.go
authsqlite/directory.go
authsqlite/directory_test.go
authsqlite/profile.go
authsqlite/profile_test.go
authsqlite/account.go authsqlite/account.go
authsqlite/account_test.go authsqlite/account_test.go
authsqlite/access.go authsqlite/access.go
@@ -45,6 +55,10 @@ authsqlite/assisted_recovery.go
authsqlite/bootstrap.go authsqlite/bootstrap.go
authsqlite/bootstrap_test.go authsqlite/bootstrap_test.go
authsqlite/organizations.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.go
authsqlite/passkey_test.go authsqlite/passkey_test.go
authsqlite/recovery.go authsqlite/recovery.go
@@ -54,6 +68,13 @@ authwebauthn/service_test.go
authwebauthn/types.go authwebauthn/types.go
bootstrap/bootstrap.go bootstrap/bootstrap.go
bootstrap/bootstrap_test.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 media/media.go
media/media_test.go media/media_test.go
medialocal/store.go medialocal/store.go
@@ -62,6 +83,7 @@ internal/webauthnvendored/
docs/ADOPTION.md docs/ADOPTION.md
docs/ARCHITECTURE.md docs/ARCHITECTURE.md
docs/DEPENDENCIES.md docs/DEPENDENCIES.md
docs/CMS.md
docs/DOGFOOD.md docs/DOGFOOD.md
docs/GETTING_STARTED.md docs/GETTING_STARTED.md
docs/MODULES.md docs/MODULES.md
@@ -83,6 +105,11 @@ requestmeta/requestmeta.go
requestmeta/requestmeta_test.go requestmeta/requestmeta_test.go
organizations/organizations.go organizations/organizations.go
organizations/organizations_test.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-licenses.sh
scripts/check-dependencies.sh scripts/check-dependencies.sh
scripts/check-vendored-webauthn.sh scripts/check-vendored-webauthn.sh
+3
View File
@@ -15,6 +15,9 @@ while IFS= read -r path; do
fi fi
done < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) | LC_ALL=C sort >"$temporary/expected" done < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) | LC_ALL=C sort >"$temporary/expected"
diff -u "$temporary/expected" "$temporary/actual" 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' private_word='PRI''VATE'
token_word='to''ken' 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" private_pattern="BEGIN (RSA|OPENSSH|EC) ${private_word} KEY|Authorization: ${token_word}|/home/"'cole'"|/mnt/c/"'Users'"|"'eqlwiki'"-deploy|"'crspeelman'"@gmail\\.com"