Add bounded administrative identity directories
verify / verify (push) Successful in 4m31s

This commit is contained in:
2026-09-05 01:19:57 -04:00
parent d476179148
commit 7c68a3499a
11 changed files with 352 additions and 5 deletions
+12
View File
@@ -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.
+3 -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
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
+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)
}
+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)
}
}
+7
View File
@@ -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
+1 -1
View File
@@ -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
```
+1 -1
View File
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
and request the containing module at an exact version:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.24
go get gamertan.com/web/requestmeta@v0.1.0-preview.25
```
Only imported packages are compiled and linked. The packages nevertheless
+19
View File
@@ -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,
+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)
}
+4
View File
@@ -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