Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c68a3499a | ||
|
|
d476179148 |
@@ -2,6 +2,31 @@
|
|||||||
|
|
||||||
# Changelog
|
# 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.
|
||||||
|
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
|
## v0.1.0-preview.23 — 2026-09-05
|
||||||
|
|
||||||
- Add atomic direct organization role sets with optimistic binding IDs, current
|
- Add atomic direct organization role sets with optimistic binding IDs, current
|
||||||
|
|||||||
@@ -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.23`. 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.
|
> release. Linux is the maintained release platform.
|
||||||
|
|
||||||
## Why Web Foundations?
|
## Why Web Foundations?
|
||||||
@@ -57,14 +57,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.23
|
go get gamertan.com/web@v0.1.0-preview.25
|
||||||
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.23
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.25
|
||||||
```
|
```
|
||||||
|
|
||||||
The version belongs to the `gamertan.com/web` module. See the
|
The version belongs to the `gamertan.com/web` module. See the
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrDirectoryQuery = errors.New("auth: invalid directory query")
|
||||||
|
|
||||||
|
// UserDirectoryQuery requests a bounded instance-wide identity listing. Search
|
||||||
|
// is literal text, not a query language. AfterID is an exclusive stable-ID cursor;
|
||||||
|
// Limit defaults to 50 and may not exceed 200.
|
||||||
|
type UserDirectoryQuery struct {
|
||||||
|
Search, AfterID string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserDirectoryPage struct {
|
||||||
|
Users []User
|
||||||
|
NextID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDirectoryRepository is an optional administrative read capability, not an
|
||||||
|
// extension of ordinary authentication. Callers MUST authorize instance-wide
|
||||||
|
// identity access before each call. Results include incomplete/inactive accounts
|
||||||
|
// but never credentials, session material, recovery codes or permission grants.
|
||||||
|
// Pagination is a current view, not a snapshot across requests.
|
||||||
|
type UserDirectoryRepository interface {
|
||||||
|
UserDirectory(context.Context, UserDirectoryQuery) (UserDirectoryPage, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/organizations"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ auth.UserDirectoryRepository = (*Store)(nil)
|
||||||
|
var _ organizations.DirectoryRepository = (*Store)(nil)
|
||||||
|
|
||||||
|
// UserDirectory is an administrative read; the adapter cannot infer application
|
||||||
|
// authorization. Search covers ID, username, email and display name. SQLite LIKE
|
||||||
|
// folds ASCII case; non-ASCII display-name text matches with its original case.
|
||||||
|
func (store *Store) UserDirectory(ctx context.Context, query auth.UserDirectoryQuery) (auth.UserDirectoryPage, error) {
|
||||||
|
pattern, limit, valid := directoryQuery(query.Search, query.AfterID, query.Limit)
|
||||||
|
if !valid {
|
||||||
|
return auth.UserDirectoryPage{}, auth.ErrDirectoryQuery
|
||||||
|
}
|
||||||
|
rows, err := store.db.QueryContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at
|
||||||
|
FROM gwf_users WHERE id>? AND (?='' OR id=? OR username_normalized LIKE ? ESCAPE '\' OR email_normalized LIKE ? ESCAPE '\' OR display_name LIKE ? ESCAPE '\')
|
||||||
|
ORDER BY id LIMIT ?`, query.AfterID, strings.TrimSpace(query.Search), strings.TrimSpace(query.Search), strings.ToLower(pattern), strings.ToLower(pattern), pattern, limit+1)
|
||||||
|
if err != nil {
|
||||||
|
return auth.UserDirectoryPage{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
page := auth.UserDirectoryPage{Users: make([]auth.User, 0, limit)}
|
||||||
|
for rows.Next() {
|
||||||
|
user, err := scanPasskeyUser(rows)
|
||||||
|
if err != nil {
|
||||||
|
return auth.UserDirectoryPage{}, err
|
||||||
|
}
|
||||||
|
page.Users = append(page.Users, user)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return auth.UserDirectoryPage{}, err
|
||||||
|
}
|
||||||
|
if len(page.Users) > limit {
|
||||||
|
page.Users = page.Users[:limit]
|
||||||
|
page.NextID = page.Users[limit-1].ID
|
||||||
|
}
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrganizationDirectory reads all personal/business and active/archived records.
|
||||||
|
// It does not join membership, grant access, or choose a merchant. Search covers
|
||||||
|
// exact ID and literal slug/name text using SQLite's ASCII case folding.
|
||||||
|
func (store *Store) OrganizationDirectory(ctx context.Context, query organizations.DirectoryQuery) (organizations.DirectoryPage, error) {
|
||||||
|
pattern, limit, valid := directoryQuery(query.Search, query.AfterID, query.Limit)
|
||||||
|
if !valid {
|
||||||
|
return organizations.DirectoryPage{}, organizations.ErrDirectoryQuery
|
||||||
|
}
|
||||||
|
rows, err := store.db.QueryContext(ctx, `SELECT id,slug,name,status,personal,revision,created_at,updated_at
|
||||||
|
FROM gwf_organizations WHERE id>? AND (?='' OR id=? OR slug LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\')
|
||||||
|
ORDER BY id LIMIT ?`, query.AfterID, strings.TrimSpace(query.Search), strings.TrimSpace(query.Search), pattern, pattern, limit+1)
|
||||||
|
if err != nil {
|
||||||
|
return organizations.DirectoryPage{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
page := organizations.DirectoryPage{Organizations: make([]organizations.Organization, 0, limit)}
|
||||||
|
for rows.Next() {
|
||||||
|
var value organizations.Organization
|
||||||
|
var created, updated int64
|
||||||
|
if err = rows.Scan(&value.ID, &value.Slug, &value.Name, &value.Status, &value.Personal, &value.Revision, &created, &updated); err != nil {
|
||||||
|
return organizations.DirectoryPage{}, err
|
||||||
|
}
|
||||||
|
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||||
|
page.Organizations = append(page.Organizations, value)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return organizations.DirectoryPage{}, err
|
||||||
|
}
|
||||||
|
if len(page.Organizations) > limit {
|
||||||
|
page.Organizations = page.Organizations[:limit]
|
||||||
|
page.NextID = page.Organizations[limit-1].ID
|
||||||
|
}
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func directoryQuery(search, after string, limit int) (string, int, bool) {
|
||||||
|
if !text(search, 128, true) || after != "" && !opaqueID(after) || limit < 0 || limit > 200 {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
if limit == 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
// Wildcards and the escape character are literal user text, never operators.
|
||||||
|
pattern := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.TrimSpace(search)) + "%"
|
||||||
|
return pattern, limit, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/organizations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstanceDirectoriesAreBoundedCredentialFreeAndIndependentOfMembership(t *testing.T) {
|
||||||
|
store, err := Open(filepath.Join(t.TempDir(), "directory.sqlite"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
ctx := t.Context()
|
||||||
|
for index := 0; index < 205; index++ {
|
||||||
|
id := fmt.Sprintf("record-%03d", index)
|
||||||
|
status := []string{"active", "suspended", "disabled"}[index%3]
|
||||||
|
_, err = store.db.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,1,1)`, id, id, id, id+"@example.test", id+"@example.test", "Person "+id, status, index%2, index%5 == 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = store.db.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,status,revision,created_at,updated_at) VALUES(?,?,?,?,?,1,1,1)`, id, id, "Business "+id, index%2, []string{"active", "archived"}[index%2])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
users, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{})
|
||||||
|
if err != nil || len(users.Users) != 50 || users.NextID != "record-049" {
|
||||||
|
t.Fatalf("default users: %d %q %v", len(users.Users), users.NextID, err)
|
||||||
|
}
|
||||||
|
if !users.Users[0].RegistrationPending || users.Users[1].Status != "suspended" || !users.Users[1].PasswordChangeRequired || users.Users[2].Status != "disabled" {
|
||||||
|
t.Fatal("administrative account states were hidden")
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(users)
|
||||||
|
for _, secret := range []string{"password_hash", "Session", "Digest", "Credential", "Recovery"} {
|
||||||
|
if strings.Contains(string(encoded), secret) {
|
||||||
|
t.Fatalf("directory leaked credential field %s", secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, size := range []int{1, 50, 200} {
|
||||||
|
userAfter, orgAfter, count := "", "", 0
|
||||||
|
for {
|
||||||
|
users, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{AfterID: userAfter, Limit: size})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
orgs, err := store.OrganizationDirectory(ctx, organizations.DirectoryQuery{AfterID: orgAfter, Limit: size})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(users.Users) != len(orgs.Organizations) || len(users.Users) > size {
|
||||||
|
t.Fatal("invalid page bound")
|
||||||
|
}
|
||||||
|
for index, user := range users.Users {
|
||||||
|
want := fmt.Sprintf("record-%03d", count)
|
||||||
|
if user.ID != want || orgs.Organizations[index].ID != want {
|
||||||
|
t.Fatalf("pagination skipped/duplicated %s", want)
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if users.NextID == "" || orgs.NextID == "" {
|
||||||
|
if users.NextID != orgs.NextID || count != 205 {
|
||||||
|
t.Fatalf("early end: %d", count)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
userAfter, orgAfter = users.NextID, orgs.NextID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orgs, err := store.OrganizationDirectory(ctx, organizations.DirectoryQuery{Limit: 2})
|
||||||
|
if err != nil || orgs.Organizations[0].Personal || !orgs.Organizations[1].Personal || orgs.Organizations[1].Status != "archived" {
|
||||||
|
t.Fatal("personal/archived organizations omitted")
|
||||||
|
}
|
||||||
|
// A display-name change cannot move a record behind a stable-ID cursor.
|
||||||
|
if _, err = store.db.Exec(`UPDATE gwf_users SET display_name='AAA' WHERE id='record-050'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
next, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{AfterID: "record-049", Limit: 1})
|
||||||
|
if err != nil || next.Users[0].ID != "record-050" {
|
||||||
|
t.Fatal("name change disturbed cursor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceDirectoryLiteralSearchValidationAndCancellation(t *testing.T) {
|
||||||
|
store, err := Open(filepath.Join(t.TempDir(), "directory.sqlite"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
_, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES('person-001','Alice','alice','Alice@example.test','alice@example.test','Élodie 50%_\ works','active',1,1)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = store.db.Exec(`INSERT INTO gwf_organizations(id,slug,name,personal,status,revision,created_at,updated_at) VALUES('company-001','alice-company','Élodie 50%_\ works',0,'active',1,1,1)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, search := range []string{"", " ALICE ", "example.test", "person-001", "Élodie", `50%_\`} {
|
||||||
|
page, err := store.UserDirectory(t.Context(), auth.UserDirectoryQuery{Search: search})
|
||||||
|
if err != nil || len(page.Users) != 1 || page.NextID != "" {
|
||||||
|
t.Errorf("user literal search %q: %#v %v", search, page, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, search := range []string{"", "ALICE", "company-001", "Élodie", `50%_\`} {
|
||||||
|
page, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: search})
|
||||||
|
if err != nil || len(page.Organizations) != 1 || page.NextID != "" {
|
||||||
|
t.Errorf("organization literal search %q: %#v %v", search, page, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, search := range []string{"absent", "%' OR 1=1 --", "%_%", "\\_%"} {
|
||||||
|
users, err := store.UserDirectory(t.Context(), auth.UserDirectoryQuery{Search: search})
|
||||||
|
if err != nil || users.Users == nil || len(users.Users) != 0 {
|
||||||
|
t.Errorf("nonliteral user search %q", search)
|
||||||
|
}
|
||||||
|
orgs, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: search})
|
||||||
|
if err != nil || orgs.Organizations == nil || len(orgs.Organizations) != 0 {
|
||||||
|
t.Errorf("nonliteral org search %q", search)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, query := range []auth.UserDirectoryQuery{{Search: strings.Repeat("a", 129)}, {Search: "bad\x00value"}, {Search: "bad\nvalue"}, {Search: "\xff"}, {AfterID: "bad/id"}, {AfterID: strings.Repeat("a", 129)}, {Limit: -1}, {Limit: 201}} {
|
||||||
|
if _, err := store.UserDirectory(t.Context(), query); !errors.Is(err, auth.ErrDirectoryQuery) {
|
||||||
|
t.Errorf("invalid user query accepted: %#v %v", query, err)
|
||||||
|
}
|
||||||
|
if _, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: query.Search, AfterID: query.AfterID, Limit: query.Limit}); !errors.Is(err, organizations.ErrDirectoryQuery) {
|
||||||
|
t.Errorf("invalid org query accepted: %#v %v", query, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
if _, err = store.UserDirectory(ctx, auth.UserDirectoryQuery{}); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("user cancellation: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = store.OrganizationDirectory(ctx, organizations.DirectoryQuery{}); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("organization cancellation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -408,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")
|
||||||
}
|
}
|
||||||
@@ -416,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
|
||||||
@@ -535,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
|
||||||
}
|
}
|
||||||
@@ -543,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)
|
||||||
@@ -620,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
|
||||||
}
|
}
|
||||||
@@ -628,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)
|
||||||
@@ -691,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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,21 @@ application concern belongs in the shared module.
|
|||||||
|
|
||||||
## Gamertan accounts and commerce
|
## 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
|
||||||
|
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 customer may need both purchasing and billing access. Replacing one role at
|
||||||
a time would create partial permission states and misleading audit history.
|
a time would create partial permission states and misleading audit history.
|
||||||
The role-set extension commits all direct roles together with optimistic
|
The role-set extension commits all direct roles together with optimistic
|
||||||
|
|||||||
@@ -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.23
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.25
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.23
|
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
|
||||||
|
|||||||
@@ -40,6 +40,25 @@ never accept the owner-role policy or actor identity from submitted fields.
|
|||||||
|
|
||||||
## Creating an organization with an owner
|
## 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
|
For an existing authenticated user creating a business, use
|
||||||
`CreateOwnedOrganization` with `OwnerRole` configured when constructing the
|
`CreateOwnedOrganization` with `OwnerRole` configured when constructing the
|
||||||
service. Seed that role first. This commits the organization, active membership,
|
service. Seed that role first. This commits the organization, active membership,
|
||||||
@@ -75,6 +94,17 @@ owns atomic public signup, including personal organization and credentials.
|
|||||||
|
|
||||||
## Membership and access lifecycle
|
## 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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -414,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")
|
||||||
@@ -422,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
|
||||||
@@ -531,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 ||
|
||||||
@@ -540,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,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")
|
||||||
@@ -579,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,37 @@ type OwnedOrganizationRepository interface {
|
|||||||
CreateOwnedOrganization(context.Context, OwnedOrganization) error
|
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
|
// CreateOwnedOrganization grants the configured OwnerRole to the initial owner
|
||||||
// inside the creation transaction. Applications authorize creation and choose
|
// inside the creation transaction. Applications authorize creation and choose
|
||||||
// OwnerRole when constructing the service, never from a submitted role name.
|
// OwnerRole when constructing the service, never from a submitted role name.
|
||||||
|
|||||||
@@ -92,3 +92,22 @@ func TestOwnedOrganizationDoesNotReturnUncommittedIdentity(t *testing.T) {
|
|||||||
t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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/password.go
|
auth/password.go
|
||||||
auth/password_test.go
|
auth/password_test.go
|
||||||
authrecovery/recovery.go
|
authrecovery/recovery.go
|
||||||
@@ -41,6 +42,8 @@ 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/account.go
|
authsqlite/account.go
|
||||||
authsqlite/account_test.go
|
authsqlite/account_test.go
|
||||||
authsqlite/access.go
|
authsqlite/access.go
|
||||||
@@ -50,6 +53,7 @@ authsqlite/bootstrap_test.go
|
|||||||
authsqlite/organizations.go
|
authsqlite/organizations.go
|
||||||
authsqlite/owned_organization.go
|
authsqlite/owned_organization.go
|
||||||
authsqlite/owned_organization_test.go
|
authsqlite/owned_organization_test.go
|
||||||
|
authsqlite/owned_management_test.go
|
||||||
authsqlite/role_sets_test.go
|
authsqlite/role_sets_test.go
|
||||||
authsqlite/passkey.go
|
authsqlite/passkey.go
|
||||||
authsqlite/passkey_test.go
|
authsqlite/passkey_test.go
|
||||||
@@ -90,6 +94,7 @@ requestmeta/requestmeta_test.go
|
|||||||
organizations/organizations.go
|
organizations/organizations.go
|
||||||
organizations/organizations_test.go
|
organizations/organizations_test.go
|
||||||
organizations/owned.go
|
organizations/owned.go
|
||||||
|
organizations/directory.go
|
||||||
organizations/owned_test.go
|
organizations/owned_test.go
|
||||||
organizations/role_invitations.go
|
organizations/role_invitations.go
|
||||||
organizations/role_invitations_test.go
|
organizations/role_invitations_test.go
|
||||||
|
|||||||
Reference in New Issue
Block a user