From 7c68a3499add62294be62c67b5ad35c5a980bdb5 Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Sat, 5 Sep 2026 01:19:57 -0400 Subject: [PATCH] Add bounded administrative identity directories --- CHANGELOG.md | 12 +++ README.md | 6 +- auth/directory.go | 32 ++++++++ authsqlite/directory.go | 95 ++++++++++++++++++++++ authsqlite/directory_test.go | 146 ++++++++++++++++++++++++++++++++++ docs/DOGFOOD.md | 7 ++ docs/GETTING_STARTED.md | 2 +- docs/MODULES.md | 2 +- docs/ORGANIZATIONS.md | 19 +++++ organizations/directory.go | 32 ++++++++ scripts/public-snapshot.allow | 4 + 11 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 auth/directory.go create mode 100644 authsqlite/directory.go create mode 100644 authsqlite/directory_test.go create mode 100644 organizations/directory.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 180bff8..33d5904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ # Changelog +## 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. diff --git a/README.md b/README.md index 3922e37..f32401d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and deployment. Adopt one boundary at a time; Go compiles and links only the packages you import. -> **Public preview:** `v0.1.0-preview.24`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.25`. APIs may change before a stable > release. Linux is the maintained release platform. ## Why Web Foundations? @@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy. Pin the preview in an application module: ```bash -go get gamertan.com/web@v0.1.0-preview.24 +go get gamertan.com/web@v0.1.0-preview.25 go mod verify ``` An application may name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.24 +go get gamertan.com/web/requestmeta@v0.1.0-preview.25 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/auth/directory.go b/auth/directory.go new file mode 100644 index 0000000..f1e34b2 --- /dev/null +++ b/auth/directory.go @@ -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) +} diff --git a/authsqlite/directory.go b/authsqlite/directory.go new file mode 100644 index 0000000..5934a7f --- /dev/null +++ b/authsqlite/directory.go @@ -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 +} diff --git a/authsqlite/directory_test.go b/authsqlite/directory_test.go new file mode 100644 index 0000000..02b4b1c --- /dev/null +++ b/authsqlite/directory_test.go @@ -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) + } +} diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index 72b127a..b05ac6a 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -8,6 +8,13 @@ application concern belongs in the shared module. ## Gamertan accounts and commerce +- 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 diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 8babbb5..d9db543 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.24 +go get gamertan.com/web/requestmeta@v0.1.0-preview.25 go mod verify ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index e2bd6ea..16c2c05 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta" and request the containing module at an exact version: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.24 +go get gamertan.com/web/requestmeta@v0.1.0-preview.25 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/ORGANIZATIONS.md b/docs/ORGANIZATIONS.md index e84bc1a..88e51e9 100644 --- a/docs/ORGANIZATIONS.md +++ b/docs/ORGANIZATIONS.md @@ -40,6 +40,25 @@ 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, diff --git a/organizations/directory.go b/organizations/directory.go new file mode 100644 index 0000000..ace1266 --- /dev/null +++ b/organizations/directory.go @@ -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) +} diff --git a/scripts/public-snapshot.allow b/scripts/public-snapshot.allow index 5384e6d..1f9e8d4 100644 --- a/scripts/public-snapshot.allow +++ b/scripts/public-snapshot.allow @@ -29,6 +29,7 @@ analytics/fuzz_test.go analytics/geo.go auth/auth.go auth/context.go +auth/directory.go auth/password.go auth/password_test.go authrecovery/recovery.go @@ -41,6 +42,8 @@ authhttp/passkey.go authhttp/passkey_test.go authsqlite/store.go authsqlite/store_test.go +authsqlite/directory.go +authsqlite/directory_test.go authsqlite/account.go authsqlite/account_test.go authsqlite/access.go @@ -91,6 +94,7 @@ requestmeta/requestmeta_test.go organizations/organizations.go organizations/organizations_test.go organizations/owned.go +organizations/directory.go organizations/owned_test.go organizations/role_invitations.go organizations/role_invitations_test.go