Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a16283efd7 | ||
|
|
7c68a3499a | ||
|
|
d476179148 | ||
|
|
f142ac23a9 | ||
|
|
c0986168bc |
@@ -0,0 +1,10 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MPL-2.0 -->
|
||||||
|
|
||||||
|
# AI assistance
|
||||||
|
|
||||||
|
Codex has materially assisted with implementation, tests, documentation, and
|
||||||
|
integration work in this project, including the organization and access-control
|
||||||
|
extensions. Assistance is disclosed here rather than repeated in every commit
|
||||||
|
subject. Repository tests, review, and release evidence—not the use of a
|
||||||
|
particular tool—determine readiness. Automated checks do not imply that every
|
||||||
|
line has received independent human review.
|
||||||
@@ -2,6 +2,69 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.1.0-preview.26 — 2026-09-05
|
||||||
|
|
||||||
|
- Add optional self-profile readers and revision-checked username/display-name
|
||||||
|
writes. Recheck the active session, account and expected revision atomically
|
||||||
|
with a secret-free audit; preserve immutable user identity and ownership.
|
||||||
|
- Username edits revoke other sessions but preserve the acting session. A
|
||||||
|
password-confirmed write can require the exact verified credential hash,
|
||||||
|
rejecting a concurrent password reset. Applications own reauthentication,
|
||||||
|
operation-bound passkey approval, CSRF/origin checks and rate/concurrency limits.
|
||||||
|
- Add explicit SQLite schema 11 for monotonic profile revisions. Existing rows
|
||||||
|
begin at revision 1; startup with migrations disabled rejects older schemas.
|
||||||
|
Do not run older writers against schema 11 as a database rollback strategy.
|
||||||
|
- Email changes are deliberately absent; pending-address verification and mail
|
||||||
|
delivery are separate work. Test invalid/restricted sessions, collisions,
|
||||||
|
concurrent/stale edits, audit rollback, restart and schema-10 migration.
|
||||||
|
|
||||||
|
## v0.1.0-preview.25 — 2026-09-05
|
||||||
|
|
||||||
|
- Add optional, credential-free user and organization directory readers for
|
||||||
|
application-authorized instance administration. They include inactive/pending
|
||||||
|
users and personal/archived organizations independently of membership.
|
||||||
|
- Bound literal searches and stable-ID pagination to at most 200 returned
|
||||||
|
records. Queries do not load passwords, sessions, recovery material, invitations
|
||||||
|
or role grants; they grant no authority. Applications must authorize each read.
|
||||||
|
- Cover pagination, renamed records, literal SQL/wildcard input, Unicode text,
|
||||||
|
invalid bounds and cancellation. Schema 10 and existing repository contracts
|
||||||
|
remain unchanged; source exports include the new optional interfaces/readers.
|
||||||
|
|
||||||
|
## v0.1.0-preview.24 — 2026-09-05
|
||||||
|
|
||||||
|
- Add explicit owner-managed profile and optimistic membership operations.
|
||||||
|
Current direct ownership is checked inside the SQLite write transaction even
|
||||||
|
when the target is an ordinary member. Active account/membership, non-personal
|
||||||
|
organization, last-owner, optimistic state, and atomic audit requirements remain
|
||||||
|
intact. Existing delegated-administrator APIs retain their behavior.
|
||||||
|
- Require `OwnerManagedRepository` support without a preflight-only fallback.
|
||||||
|
No schema migration is added; schema 10 remains current.
|
||||||
|
- Test revoked, narrowed, suspended, removed and incomplete actor authority,
|
||||||
|
archived/personal organizations, stale and concurrent submissions, and rollback
|
||||||
|
of profile, membership, team, role and invitation effects after audit failure.
|
||||||
|
|
||||||
|
## v0.1.0-preview.23 — 2026-09-05
|
||||||
|
|
||||||
|
- Add atomic direct organization role sets with optimistic binding IDs, current
|
||||||
|
direct-owner authorization, last-owner protection, and a single audit. Roles
|
||||||
|
may be combined without changing narrower or team grants.
|
||||||
|
- Add bounded multiple-role invitations and opt-in owner-managed invitation
|
||||||
|
policy. Persist the required grantor authority and recheck it at acceptance,
|
||||||
|
together with active, fully registered users and recipient email. Suspended
|
||||||
|
members cannot reactivate themselves by accepting an older invitation.
|
||||||
|
- Invitations enroll new members rather than adding permissions to existing
|
||||||
|
members. Membership removal revokes pending invitations for that recipient
|
||||||
|
in the same transaction, preventing an older offer from restoring access.
|
||||||
|
- Add SQLite schema 10 for invitation role sets and stored owner authority.
|
||||||
|
Legacy single-role data remains readable after explicit migration; older
|
||||||
|
schema-9 applications are not approved writers of the migrated database.
|
||||||
|
Custom repositories must implement the role-set extensions before exposing
|
||||||
|
these operations; there is no non-atomic fallback.
|
||||||
|
- Include the owned-organization implementation and tests in the public-source
|
||||||
|
export, and compile the exported tree to catch incomplete source distributions.
|
||||||
|
- Cover competing changes and invitation acceptance, failure rollback, stale
|
||||||
|
owners, unsupported adapters, and migration of legacy invitations.
|
||||||
|
|
||||||
## v0.1.0-preview.22 — 2026-09-04
|
## v0.1.0-preview.22 — 2026-09-04
|
||||||
|
|
||||||
- Add `organizations.CreateOwnedOrganization` for atomic creation of an existing
|
- Add `organizations.CreateOwnedOrganization` for atomic creation of an existing
|
||||||
|
|||||||
@@ -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.22`. APIs may change before a stable
|
> **Public preview:** `v0.1.0-preview.26`. 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.22
|
go get gamertan.com/web@v0.1.0-preview.26
|
||||||
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.22
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.26
|
||||||
```
|
```
|
||||||
|
|
||||||
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,65 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package access
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrRoleSetUnsupported = errors.New("access: atomic role sets are unsupported")
|
||||||
|
|
||||||
|
// RoleSetRepository commits every replacement and the audit atomically. There
|
||||||
|
// is no sequence of individual Grant/Revoke calls as a fallback.
|
||||||
|
type RoleSetRepository interface {
|
||||||
|
ReplaceOrganizationUserRoles(context.Context, []string, []Binding, string, AuditEvent) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrganizationUserRolesChange struct {
|
||||||
|
OrganizationID, UserID, ActorUserID, RequestID string
|
||||||
|
Roles, ExpectedBindingIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceOrganizationUserRoles replaces the direct organization-wide role set
|
||||||
|
// for one active member. Team and narrower grants are unaffected. This bulk
|
||||||
|
// operation requires a current direct owner inside the write transaction;
|
||||||
|
// applications still authorize their customer/merchant and allowed-role boundary.
|
||||||
|
func (service *Service) ReplaceOrganizationUserRoles(ctx context.Context, input OrganizationUserRolesChange) ([]Binding, error) {
|
||||||
|
repository, ok := service.repository.(RoleSetRepository)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrRoleSetUnsupported
|
||||||
|
}
|
||||||
|
if service.ownerRole == "" || !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || !text(input.RequestID, 128, true) || len(input.Roles) < 1 || len(input.Roles) > 16 {
|
||||||
|
return nil, errors.New("access: invalid organization role set")
|
||||||
|
}
|
||||||
|
roles := append([]string(nil), input.Roles...)
|
||||||
|
sort.Strings(roles)
|
||||||
|
for i, role := range roles {
|
||||||
|
if _, exists := service.policy.Roles[role]; !exists || i > 0 && roles[i-1] == role {
|
||||||
|
return nil, errors.New("access: unknown or duplicate role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expected, err := canonicalBindingIDs(input.ExpectedBindingIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := service.now().UTC()
|
||||||
|
bindings := make([]Binding, 0, len(roles))
|
||||||
|
for _, role := range roles {
|
||||||
|
id, err := randomID(service.random)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bindings = append(bindings, Binding{ID: id, SubjectKind: User, SubjectID: input.UserID, Role: role, Scope: Scope{OrganizationID: input.OrganizationID}, GrantedBy: input.ActorUserID, GrantedAt: now})
|
||||||
|
}
|
||||||
|
id, err := randomID(service.random)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
audit := AuditEvent{ID: id, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.role.replace", ResourceType: "user", ResourceID: input.UserID, RequestID: input.RequestID, Summary: "Direct organization roles replaced", CreatedAt: now}
|
||||||
|
if err := repository.ReplaceOrganizationUserRoles(ctx, expected, bindings, service.ownerRole, audit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bindings, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package access
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roleSetRepositoryStub struct {
|
||||||
|
repositoryStub
|
||||||
|
calls int
|
||||||
|
expected []string
|
||||||
|
roles []Binding
|
||||||
|
audit AuditEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *roleSetRepositoryStub) ReplaceOrganizationUserRoles(_ context.Context, expected []string, bindings []Binding, _ string, audit AuditEvent) error {
|
||||||
|
r.calls++
|
||||||
|
r.expected, r.roles, r.audit = expected, bindings, audit
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleSetServiceBoundsAndCanonicalCopies(t *testing.T) {
|
||||||
|
policy := Policy{Roles: map[string]string{"owner": "Owner", "buyer": "Buyer", "billing": "Billing"}, Permissions: map[string]string{"purchase": "Purchase"}, Grants: map[string][]string{"owner": {"purchase"}, "buyer": {"purchase"}, "billing": {}}}
|
||||||
|
r := &roleSetRepositoryStub{}
|
||||||
|
service, err := New(r, policy, Options{OwnerRole: "owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := OrganizationUserRolesChange{OrganizationID: "organization-123", UserID: "member-12345678", ActorUserID: "owner-12345678", Roles: []string{"buyer", "billing"}, ExpectedBindingIDs: []string{"binding-second", "binding-first"}, RequestID: "request-roles"}
|
||||||
|
bindings, err := service.ReplaceOrganizationUserRoles(t.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r.calls != 1 || len(bindings) != 2 || bindings[0].Role != "billing" || bindings[1].Role != "buyer" || bindings[0].ID == bindings[1].ID || !slices.Equal(r.expected, []string{"binding-first", "binding-second"}) {
|
||||||
|
t.Fatalf("bindings=%v expected=%v calls=%d", bindings, r.expected, r.calls)
|
||||||
|
}
|
||||||
|
if !slices.Equal(input.Roles, []string{"buyer", "billing"}) || !slices.Equal(input.ExpectedBindingIDs, []string{"binding-second", "binding-first"}) {
|
||||||
|
t.Fatal("caller input was sorted in place")
|
||||||
|
}
|
||||||
|
if r.audit.RequestID != input.RequestID || r.audit.ActorUserID != input.ActorUserID || r.audit.ResourceID != input.UserID {
|
||||||
|
t.Fatalf("audit=%+v", r.audit)
|
||||||
|
}
|
||||||
|
for _, roles := range [][]string{nil, {"buyer", "buyer"}, {"missing"}, make([]string, 17)} {
|
||||||
|
invalid := input
|
||||||
|
invalid.Roles = roles
|
||||||
|
if _, err = service.ReplaceOrganizationUserRoles(t.Context(), invalid); err == nil {
|
||||||
|
t.Fatalf("invalid roles=%v", roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, expected := range [][]string{{"bad"}, {"binding-first", "binding-first"}, make([]string, 17)} {
|
||||||
|
invalid := input
|
||||||
|
invalid.ExpectedBindingIDs = expected
|
||||||
|
if _, err = service.ReplaceOrganizationUserRoles(t.Context(), invalid); err == nil {
|
||||||
|
t.Fatalf("invalid IDs=%v", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.calls != 1 {
|
||||||
|
t.Fatal("invalid input reached repository")
|
||||||
|
}
|
||||||
|
legacy, err := New(&repositoryStub{}, policy, Options{OwnerRole: "owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = legacy.ReplaceOrganizationUserRoles(t.Context(), input); !errors.Is(err, ErrRoleSetUnsupported) {
|
||||||
|
t.Fatalf("fallback=%v", err)
|
||||||
|
}
|
||||||
|
broken, err := New(r, policy, Options{OwnerRole: "owner", Random: strings.NewReader("")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = broken.ReplaceOrganizationUserRoles(t.Context(), input); err == nil || r.calls != 1 {
|
||||||
|
t.Fatal("random failure reached storage")
|
||||||
|
}
|
||||||
|
withoutOwner, err := New(r, policy, Options{Now: func() time.Time { return time.Unix(2000, 0) }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = withoutOwner.ReplaceOrganizationUserRoles(t.Context(), input); err == nil || r.calls != 1 {
|
||||||
|
t.Fatal("role set without owner boundary accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrDirectoryQuery = errors.New("auth: invalid directory query")
|
||||||
|
|
||||||
|
// UserDirectoryQuery requests a bounded instance-wide identity listing. Search
|
||||||
|
// is literal text, not a query language. AfterID is an exclusive stable-ID cursor;
|
||||||
|
// Limit defaults to 50 and may not exceed 200.
|
||||||
|
type UserDirectoryQuery struct {
|
||||||
|
Search, AfterID string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserDirectoryPage struct {
|
||||||
|
Users []User
|
||||||
|
NextID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDirectoryRepository is an optional administrative read capability, not an
|
||||||
|
// extension of ordinary authentication. Callers MUST authorize instance-wide
|
||||||
|
// identity access before each call. Results include incomplete/inactive accounts
|
||||||
|
// but never credentials, session material, recovery codes or permission grants.
|
||||||
|
// Pagination is a current view, not a snapshot across requests.
|
||||||
|
type UserDirectoryRepository interface {
|
||||||
|
UserDirectory(context.Context, UserDirectoryQuery) (UserDirectoryPage, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrProfileInput = errors.New("auth: invalid profile change")
|
||||||
|
ErrProfileAccess = errors.New("auth: profile session is unavailable")
|
||||||
|
ErrProfileConflict = errors.New("auth: profile changed; reload before editing")
|
||||||
|
ErrUsernameUnavailable = errors.New("auth: username is unavailable")
|
||||||
|
)
|
||||||
|
|
||||||
|
// OwnProfile contains mutable identity, not credentials or organization roles.
|
||||||
|
// Revision is independent of timestamps and increases for every profile edit.
|
||||||
|
type OwnProfile struct {
|
||||||
|
UserID, Username, Email, DisplayName string
|
||||||
|
Revision int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileEdit is a trusted repository command, not an HTTP input model. The
|
||||||
|
// application must authenticate the session, validate CSRF/origin and rate-limit
|
||||||
|
// mutations. Username edits additionally require recent reauthentication (and
|
||||||
|
// any account-specific MFA). For password reauthentication, supply the verified
|
||||||
|
// hash so a concurrent password reset invalidates the write. After verified
|
||||||
|
// passkey approval, leave it empty. Do not log or serialize this command.
|
||||||
|
type ProfileEdit struct {
|
||||||
|
UserID string
|
||||||
|
SessionDigest [32]byte
|
||||||
|
ExpectedRevision int64
|
||||||
|
Field, Value string
|
||||||
|
ExpectedPasswordHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeProfileValue validates only supported fields. Email is deliberately
|
||||||
|
// absent: verified mailbox changes need a separate pending/confirmation flow.
|
||||||
|
func NormalizeProfileValue(field, value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
switch field {
|
||||||
|
case "username":
|
||||||
|
if !identifierPattern.MatchString(value) {
|
||||||
|
return "", ErrProfileInput
|
||||||
|
}
|
||||||
|
case "display_name":
|
||||||
|
if value == "" || len(value) > 128 || !utf8.ValidString(value) {
|
||||||
|
return "", ErrProfileInput
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return "", ErrProfileInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", ErrProfileInput
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnProfileRepository is optional; no change to the authentication Repository
|
||||||
|
// interface is required. It derives access from the current session, never from
|
||||||
|
// a site-wide administrator flag. Implementations atomically recheck identity,
|
||||||
|
// session and revision, mutate one field, and append the audit. Username edits
|
||||||
|
// revoke other sessions but preserve the acting session. They never reassign
|
||||||
|
// stable IDs, memberships, passkeys, billing identities or historical records.
|
||||||
|
type OwnProfileRepository interface {
|
||||||
|
OwnProfile(context.Context, [32]byte, time.Time) (OwnProfile, error)
|
||||||
|
UpdateOwnProfile(context.Context, ProfileEdit, AuditEvent) (OwnProfile, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeProfileValue(t *testing.T) {
|
||||||
|
for _, value := range []struct{ field, value, want string }{
|
||||||
|
{"username", " Reader.One ", "Reader.One"}, {"display_name", " Émilie ★ ", "Émilie ★"},
|
||||||
|
} {
|
||||||
|
got, err := NormalizeProfileValue(value.field, value.value)
|
||||||
|
if err != nil || got != value.want {
|
||||||
|
t.Fatalf("normalization: %q %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []struct{ field, value string }{
|
||||||
|
{"email", "new@example.test"}, {"role", "owner"}, {"username", "a"}, {"username", "foo@bar"},
|
||||||
|
{"display_name", ""}, {"display_name", "hello\x00world"}, {"display_name", "hello\nworld"}, {"display_name", string([]byte{0xff})},
|
||||||
|
} {
|
||||||
|
if _, err := NormalizeProfileValue(value.field, value.value); err == nil {
|
||||||
|
t.Fatalf("invalid field accepted: %s", value.field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzProfileValue(f *testing.F) {
|
||||||
|
f.Add("username", "reader.one")
|
||||||
|
f.Add("display_name", "Émilie")
|
||||||
|
f.Add("email", "a@example.test")
|
||||||
|
f.Fuzz(func(t *testing.T, field, value string) {
|
||||||
|
normal, err := NormalizeProfileValue(field, value)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(normal) == 0 || len(normal) > 128 {
|
||||||
|
t.Fatal("unbounded value")
|
||||||
|
}
|
||||||
|
again, err := NormalizeProfileValue(field, normal)
|
||||||
|
if err != nil || again != normal {
|
||||||
|
t.Fatal("unstable normalization")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+31
-9
@@ -167,9 +167,28 @@ func (store *Store) OrganizationUserBindings(ctx context.Context, organizationID
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) error {
|
func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) error {
|
||||||
if !validOrganizationRoleReplacement(expected, replacement, ownerRole, audit) {
|
return store.replaceOrganizationUserRoles(ctx, expected, []access.Binding{replacement}, ownerRole, audit, false)
|
||||||
return errors.New("authsqlite: invalid organization role replacement")
|
}
|
||||||
|
|
||||||
|
func (store *Store) ReplaceOrganizationUserRoles(ctx context.Context, expected []string, replacements []access.Binding, ownerRole string, audit access.AuditEvent) error {
|
||||||
|
return store.replaceOrganizationUserRoles(ctx, expected, replacements, ownerRole, audit, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) replaceOrganizationUserRoles(ctx context.Context, expected []string, replacements []access.Binding, ownerRole string, audit access.AuditEvent, requireOwner bool) error {
|
||||||
|
if len(replacements) < 1 || len(replacements) > 16 {
|
||||||
|
return errors.New("authsqlite: invalid organization role set")
|
||||||
}
|
}
|
||||||
|
replacement := replacements[0]
|
||||||
|
roles := make([]string, 0, len(replacements))
|
||||||
|
ids := make(map[string]bool, len(replacements))
|
||||||
|
for _, value := range replacements {
|
||||||
|
if !validOrganizationRoleReplacement(expected, value, ownerRole, audit) || value.SubjectID != replacement.SubjectID || value.Scope != replacement.Scope || value.GrantedBy != replacement.GrantedBy || !value.GrantedAt.Equal(replacement.GrantedAt) || ids[value.ID] || slices.Contains(roles, value.Role) {
|
||||||
|
return errors.New("authsqlite: invalid organization role set")
|
||||||
|
}
|
||||||
|
roles = append(roles, value.Role)
|
||||||
|
ids[value.ID] = true
|
||||||
|
}
|
||||||
|
slices.Sort(roles)
|
||||||
tx, err := store.db.BeginTx(ctx, nil)
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -183,7 +202,7 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
|
|||||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
|
||||||
WHERE organization_id=? AND user_id=? AND status='active'
|
WHERE organization_id=? AND user_id=? AND status='active'
|
||||||
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
|
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
|
||||||
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active')`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy)
|
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -195,7 +214,7 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
|
|||||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*)
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*)
|
||||||
FROM gwf_organization_memberships m
|
FROM gwf_organization_memberships m
|
||||||
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
|
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
|
||||||
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
|
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
|
||||||
WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil {
|
WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -231,10 +250,11 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
|
|||||||
if !slices.Equal(currentIDs, expected) {
|
if !slices.Equal(currentIDs, expected) {
|
||||||
return access.ErrRoleChangeConflict
|
return access.ErrRoleChangeConflict
|
||||||
}
|
}
|
||||||
if len(currentRoles) == 1 && currentRoles[0] == replacement.Role {
|
slices.Sort(currentRoles)
|
||||||
|
if slices.Equal(currentRoles, roles) {
|
||||||
return access.ErrRoleUnchanged
|
return access.ErrRoleUnchanged
|
||||||
}
|
}
|
||||||
if replacement.Role == ownerRole || slices.Contains(currentRoles, ownerRole) {
|
if requireOwner || slices.Contains(roles, ownerRole) || slices.Contains(currentRoles, ownerRole) {
|
||||||
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, replacement.Scope.OrganizationID, replacement.GrantedBy, ownerRole)
|
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, replacement.Scope.OrganizationID, replacement.GrantedBy, ownerRole)
|
||||||
if ownerErr != nil {
|
if ownerErr != nil {
|
||||||
return ownerErr
|
return ownerErr
|
||||||
@@ -243,12 +263,12 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
|
|||||||
return access.ErrOwnerAuthority
|
return access.ErrOwnerAuthority
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) {
|
if !slices.Contains(roles, ownerRole) && slices.Contains(currentRoles, ownerRole) {
|
||||||
var otherOwners int
|
var otherOwners int
|
||||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
|
||||||
FROM gwf_access_bindings b
|
FROM gwf_access_bindings b
|
||||||
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
|
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
|
||||||
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
|
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
|
||||||
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
|
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
|
||||||
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
|
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
|
||||||
AND b.revoked_at IS NULL`, replacement.Scope.OrganizationID, replacement.SubjectID, ownerRole).Scan(&otherOwners); err != nil {
|
AND b.revoked_at IS NULL`, replacement.Scope.OrganizationID, replacement.SubjectID, ownerRole).Scan(&otherOwners); err != nil {
|
||||||
@@ -265,14 +285,16 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
|
|||||||
AND revoked_at IS NULL`, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Scope.OrganizationID, replacement.SubjectID); err != nil {
|
AND revoked_at IS NULL`, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Scope.OrganizationID, replacement.SubjectID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for _, value := range replacements {
|
||||||
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at)
|
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at)
|
||||||
SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, replacement.ID, replacement.Scope.OrganizationID, replacement.SubjectID, replacement.Role, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Role)
|
SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, value.ID, value.Scope.OrganizationID, value.SubjectID, value.Role, value.GrantedBy, value.GrantedAt.Unix(), value.Role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
return errors.New("authsqlite: replacement role has not been seeded")
|
return errors.New("authsqlite: replacement role has not been seeded")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if err = appendAccessAudit(ctx, tx, audit); err != nil {
|
if err = appendAccessAudit(ctx, tx, audit); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
-30
@@ -7,6 +7,8 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gamertan.com/web/organizations"
|
"gamertan.com/web/organizations"
|
||||||
@@ -123,10 +125,28 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) CreateInvitationWithRoles(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
|
||||||
|
return store.CreateInvitation(ctx, invitation, ownerRole, audit)
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
|
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
|
||||||
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || ownerRole != "" && !safeName(ownerRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
|
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || ownerRole != "" && !safeName(ownerRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
|
||||||
return errors.New("authsqlite: invalid invitation")
|
return errors.New("authsqlite: invalid invitation")
|
||||||
}
|
}
|
||||||
|
roles, err := invitation.RoleNames()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if invitation.RequiredOwnerRole != "" && invitation.RequiredOwnerRole != ownerRole || audit.ActorUserID != invitation.InvitedByUserID || audit.Action != "invitation.create" || audit.ResourceType != "invitation" || audit.ResourceID != invitation.ID {
|
||||||
|
return errors.New("authsqlite: invalid invitation authority")
|
||||||
|
}
|
||||||
|
if ownerRole != "" && slices.Contains(roles, ownerRole) {
|
||||||
|
invitation.RequiredOwnerRole = ownerRole
|
||||||
|
}
|
||||||
|
rolesJSON, err := json.Marshal(invitation.DirectRoles)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
teamIDs, err := json.Marshal(invitation.TeamIDs)
|
teamIDs, err := json.Marshal(invitation.TeamIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -139,8 +159,8 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio
|
|||||||
if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil {
|
if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ownerRole != "" && invitation.DirectRole == ownerRole {
|
if invitation.RequiredOwnerRole != "" {
|
||||||
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, ownerRole)
|
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, invitation.RequiredOwnerRole)
|
||||||
if ownerErr != nil {
|
if ownerErr != nil {
|
||||||
return ownerErr
|
return ownerErr
|
||||||
}
|
}
|
||||||
@@ -151,9 +171,18 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio
|
|||||||
if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
|
if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at,id,direct_role,team_ids_json)
|
for _, role := range roles {
|
||||||
SELECT ?,?,?,?,?,?,?,?,? FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id
|
var count int
|
||||||
WHERE m.organization_id=? AND m.user_id=? AND m.status='active' AND o.status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.ID, invitation.DirectRole, teamIDs, invitation.OrganizationID, invitation.InvitedByUserID)
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_roles WHERE name=?`, role).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
return errors.New("authsqlite: invitation role has not been seeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at,id,direct_role,team_ids_json,direct_roles_json,required_owner_role)
|
||||||
|
SELECT ?,?,?,?,?,?,?,?,?,?,? FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id
|
||||||
|
WHERE m.organization_id=? AND m.user_id=? AND m.status='active' AND o.status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.ID, invitation.DirectRole, teamIDs, rolesJSON, invitation.RequiredOwnerRole, invitation.OrganizationID, invitation.InvitedByUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -172,8 +201,8 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now
|
|||||||
}
|
}
|
||||||
var invitation organizations.Invitation
|
var invitation organizations.Invitation
|
||||||
var created, expires int64
|
var created, expires int64
|
||||||
var teamIDs []byte
|
var teamIDs, rolesJSON []byte
|
||||||
err := store.db.QueryRowContext(ctx, `SELECT id,organization_id,email_normalized,invited_by_user_id,direct_role,team_ids_json,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.ID, &invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &invitation.DirectRole, &teamIDs, &created, &expires)
|
err := store.db.QueryRowContext(ctx, `SELECT id,organization_id,email_normalized,invited_by_user_id,direct_role,team_ids_json,direct_roles_json,required_owner_role,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.ID, &invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &invitation.DirectRole, &teamIDs, &rolesJSON, &invitation.RequiredOwnerRole, &created, &expires)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return organizations.Invitation{}, organizations.ErrInvitationNotFound
|
return organizations.Invitation{}, organizations.ErrInvitationNotFound
|
||||||
}
|
}
|
||||||
@@ -184,13 +213,20 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now
|
|||||||
if err = json.Unmarshal(teamIDs, &invitation.TeamIDs); err != nil || !validInvitationTeamIDs(invitation.TeamIDs) {
|
if err = json.Unmarshal(teamIDs, &invitation.TeamIDs); err != nil || !validInvitationTeamIDs(invitation.TeamIDs) {
|
||||||
return organizations.Invitation{}, organizations.ErrInvitationNotFound
|
return organizations.Invitation{}, organizations.ErrInvitationNotFound
|
||||||
}
|
}
|
||||||
|
if !decodeInvitationRoles(&invitation, rolesJSON) {
|
||||||
|
return organizations.Invitation{}, organizations.ErrInvitationNotFound
|
||||||
|
}
|
||||||
invitation.CreatedAt = time.Unix(created, 0).UTC()
|
invitation.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
invitation.ExpiresAt = time.Unix(expires, 0).UTC()
|
invitation.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||||
return invitation, nil
|
return invitation, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time, audit organizations.AuditEvent) error {
|
func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time, audit organizations.AuditEvent) error {
|
||||||
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) {
|
return store.AcceptInvitationWithRoles(ctx, digest, userID, "", acceptedAt, audit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) AcceptInvitationWithRoles(ctx context.Context, digest [32]byte, userID, ownerRole string, acceptedAt time.Time, audit organizations.AuditEvent) error {
|
||||||
|
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) || audit.ActorUserID != userID || ownerRole != "" && !safeName(ownerRole) {
|
||||||
return organizations.ErrInvitationNotFound
|
return organizations.ErrInvitationNotFound
|
||||||
}
|
}
|
||||||
tx, err := store.db.BeginTx(ctx, nil)
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
@@ -198,16 +234,45 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
var invitationID, organizationID, directRole, invitedBy string
|
// Serialize acceptance before reading token state, including competing users.
|
||||||
var teamIDsJSON []byte
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET expires_at=expires_at WHERE token_hash=?`, digest[:]); err != nil {
|
||||||
err = tx.QueryRowContext(ctx, `SELECT i.id,i.organization_id,i.direct_role,i.team_ids_json,i.invited_by_user_id FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized JOIN gwf_organizations o ON o.id=i.organization_id AND o.status='active' WHERE i.token_hash=? AND i.used_at IS NULL AND i.revoked_at IS NULL AND i.expires_at>?`, userID, digest[:], acceptedAt.Unix()).Scan(&invitationID, &organizationID, &directRole, &teamIDsJSON, &invitedBy)
|
return err
|
||||||
|
}
|
||||||
|
var invitationID, organizationID, directRole, invitedBy, requiredOwnerRole string
|
||||||
|
var teamIDsJSON, rolesJSON []byte
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT i.id,i.organization_id,i.direct_role,i.team_ids_json,i.invited_by_user_id,i.direct_roles_json,i.required_owner_role FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized AND u.status='active' AND u.registration_pending=0 JOIN gwf_organizations o ON o.id=i.organization_id AND o.status='active' WHERE i.token_hash=? AND i.used_at IS NULL AND i.revoked_at IS NULL AND i.expires_at>? AND NOT EXISTS (SELECT 1 FROM gwf_organization_memberships m WHERE m.organization_id=i.organization_id AND m.user_id=u.id)`, userID, digest[:], acceptedAt.Unix()).Scan(&invitationID, &organizationID, &directRole, &teamIDsJSON, &invitedBy, &rolesJSON, &requiredOwnerRole)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return organizations.ErrInvitationNotFound
|
return organizations.ErrInvitationNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,'active',?) ON CONFLICT(organization_id,user_id) DO UPDATE SET status='active'`, organizationID, userID, acceptedAt.Unix()); err != nil {
|
invitation := organizations.Invitation{DirectRole: directRole, RequiredOwnerRole: requiredOwnerRole}
|
||||||
|
if !decodeInvitationRoles(&invitation, rolesJSON) {
|
||||||
|
return organizations.ErrInvitationNotFound
|
||||||
|
}
|
||||||
|
roles, _ := invitation.RoleNames()
|
||||||
|
if audit.OrganizationID != organizationID || audit.ResourceType != "invitation" || audit.ResourceID != invitationID || audit.Action != "invitation.accept" {
|
||||||
|
return organizations.ErrInvitationNotFound
|
||||||
|
}
|
||||||
|
// Stored authority survives which application service receives the link.
|
||||||
|
// The caller's owner role also protects pre-schema-10 single-role invitations.
|
||||||
|
if requiredOwnerRole == "" && ownerRole != "" && slices.Contains(roles, ownerRole) {
|
||||||
|
requiredOwnerRole = ownerRole
|
||||||
|
}
|
||||||
|
if err = lockActiveMembershipActor(ctx, tx, organizationID, invitedBy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if requiredOwnerRole != "" {
|
||||||
|
isOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, invitedBy, requiredOwnerRole)
|
||||||
|
if ownerErr != nil {
|
||||||
|
return ownerErr
|
||||||
|
}
|
||||||
|
if !isOwner {
|
||||||
|
return organizations.ErrOwnerAuthority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,'active',?)`, organizationID, userID, acceptedAt.Unix()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var teamIDs []string
|
var teamIDs []string
|
||||||
@@ -222,11 +287,12 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if directRole != "" {
|
for i, role := range roles {
|
||||||
if !safeName(directRole) {
|
bindingID := "invite-" + invitationID
|
||||||
return organizations.ErrInvitationNotFound
|
if len(invitation.DirectRoles) > 0 {
|
||||||
|
bindingID += "-" + strconv.Itoa(i)
|
||||||
}
|
}
|
||||||
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, "invite-"+invitationID, organizationID, userID, directRole, invitedBy, acceptedAt.Unix(), directRole)
|
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, bindingID, organizationID, userID, role, invitedBy, acceptedAt.Unix(), role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -241,9 +307,6 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
|
|||||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
return organizations.ErrInvitationNotFound
|
return organizations.ErrInvitationNotFound
|
||||||
}
|
}
|
||||||
if organizationID != audit.OrganizationID {
|
|
||||||
return organizations.ErrInvitationNotFound
|
|
||||||
}
|
|
||||||
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -345,6 +408,17 @@ func (store *Store) OrganizationByID(ctx context.Context, organizationID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) UpdateOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, audit organizations.AuditEvent) error {
|
func (store *Store) UpdateOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, audit organizations.AuditEvent) error {
|
||||||
|
return store.updateOrganization(ctx, value, expectedRevision, "", audit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) UpdateOwnedOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, ownerRole string, audit organizations.AuditEvent) error {
|
||||||
|
if !safeName(ownerRole) || value.Personal || value.Status != "active" || audit.Action != "organization.update" || audit.ResourceType != "organization" || audit.ResourceID != value.ID {
|
||||||
|
return errors.New("authsqlite: invalid owner-managed organization update")
|
||||||
|
}
|
||||||
|
return store.updateOrganization(ctx, value, expectedRevision, ownerRole, audit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) updateOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, ownerRole string, audit organizations.AuditEvent) error {
|
||||||
if !validOrganization(value) || expectedRevision < 1 || value.Revision != expectedRevision+1 || !validOrganizationAudit(audit, value.ID) {
|
if !validOrganization(value) || expectedRevision < 1 || value.Revision != expectedRevision+1 || !validOrganizationAudit(audit, value.ID) {
|
||||||
return errors.New("authsqlite: invalid organization update")
|
return errors.New("authsqlite: invalid organization update")
|
||||||
}
|
}
|
||||||
@@ -353,6 +427,11 @@ func (store *Store) UpdateOrganization(ctx context.Context, value organizations.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
if ownerRole != "" {
|
||||||
|
if err = lockOrganizationOwner(ctx, tx, value.ID, audit.ActorUserID, ownerRole); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_organizations SET slug=?,name=?,status=?,revision=?,updated_at=? WHERE id=? AND revision=?`, value.Slug, value.Name, value.Status, value.Revision, value.UpdatedAt.Unix(), value.ID, expectedRevision)
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_organizations SET slug=?,name=?,status=?,revision=?,updated_at=? WHERE id=? AND revision=?`, value.Slug, value.Name, value.Status, value.Revision, value.UpdatedAt.Unix(), value.ID, expectedRevision)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -472,6 +551,14 @@ func (store *Store) SetMembershipStatus(ctx context.Context, organizationID, use
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) error {
|
func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) error {
|
||||||
|
return store.changeMembershipStatus(ctx, input, ownerRole, audit, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ChangeOwnedMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) error {
|
||||||
|
return store.changeMembershipStatus(ctx, input, ownerRole, audit, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) changeMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent, requireOwner bool) error {
|
||||||
if !validMembershipStatusChange(input, ownerRole, audit) {
|
if !validMembershipStatusChange(input, ownerRole, audit) {
|
||||||
return organizations.ErrMembershipNotFound
|
return organizations.ErrMembershipNotFound
|
||||||
}
|
}
|
||||||
@@ -480,7 +567,12 @@ func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizati
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID); err != nil {
|
if requireOwner {
|
||||||
|
err = lockOrganizationOwner(ctx, tx, input.OrganizationID, input.ActorUserID, ownerRole)
|
||||||
|
} else {
|
||||||
|
err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
|
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
|
||||||
@@ -534,6 +626,9 @@ func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID
|
|||||||
if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
|
if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err = revokePendingMembershipInvitations(ctx, tx, organizationID, userID, audit.CreatedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, userID, organizationID); err != nil {
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, userID, organizationID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -554,6 +649,14 @@ func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) error {
|
func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) error {
|
||||||
|
return store.removeMembershipIfCurrent(ctx, input, ownerRole, audit, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) RemoveOwnedMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) error {
|
||||||
|
return store.removeMembershipIfCurrent(ctx, input, ownerRole, audit, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) removeMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent, requireOwner bool) error {
|
||||||
if !validMembershipRemoval(input, ownerRole, audit) {
|
if !validMembershipRemoval(input, ownerRole, audit) {
|
||||||
return organizations.ErrMembershipNotFound
|
return organizations.ErrMembershipNotFound
|
||||||
}
|
}
|
||||||
@@ -562,7 +665,12 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID); err != nil {
|
if requireOwner {
|
||||||
|
err = lockOrganizationOwner(ctx, tx, input.OrganizationID, input.ActorUserID, ownerRole)
|
||||||
|
} else {
|
||||||
|
err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
|
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
|
||||||
@@ -578,6 +686,9 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz
|
|||||||
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
|
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err = revokePendingMembershipInvitations(ctx, tx, input.OrganizationID, input.UserID, audit.CreatedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, input.UserID, input.OrganizationID); err != nil {
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, input.UserID, input.OrganizationID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -597,13 +708,22 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Removing a member invalidates older enrollment offers too. A deliberate new
|
||||||
|
// invitation may be issued later; an old link cannot undo this transaction.
|
||||||
|
func revokePendingMembershipInvitations(ctx context.Context, tx *sql.Tx, organizationID, userID string, at time.Time) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=?
|
||||||
|
WHERE organization_id=? AND email_normalized=(SELECT email_normalized FROM gwf_users WHERE id=?)
|
||||||
|
AND used_at IS NULL AND revoked_at IS NULL`, at.Unix(), organizationID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID, actorUserID string) error {
|
func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID, actorUserID string) error {
|
||||||
// Acquire the SQLite write lock before reading the optimistic state. This
|
// Acquire the SQLite write lock before reading the optimistic state. This
|
||||||
// makes a competing lifecycle transaction observe the committed winner.
|
// makes a competing lifecycle transaction observe the committed winner.
|
||||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
|
||||||
WHERE organization_id=? AND user_id=? AND status='active'
|
WHERE organization_id=? AND user_id=? AND status='active'
|
||||||
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
|
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
|
||||||
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active')`, organizationID, actorUserID, organizationID, actorUserID)
|
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)`, organizationID, actorUserID, organizationID, actorUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -613,6 +733,27 @@ func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lockOrganizationOwner(ctx context.Context, tx *sql.Tx, organizationID, actorUserID, ownerRole string) error {
|
||||||
|
if err := lockActiveMembershipActor(ctx, tx, organizationID, actorUserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var personal bool
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT personal FROM gwf_organizations WHERE id=?`, organizationID).Scan(&personal); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if personal {
|
||||||
|
return organizations.ErrPersonalOrganization
|
||||||
|
}
|
||||||
|
owner, err := hasDirectOwnerRole(ctx, tx, organizationID, actorUserID, ownerRole)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !owner {
|
||||||
|
return organizations.ErrOwnerAuthority
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func membershipStatus(ctx context.Context, tx *sql.Tx, organizationID, userID string) (string, error) {
|
func membershipStatus(ctx context.Context, tx *sql.Tx, organizationID, userID string) (string, error) {
|
||||||
var status string
|
var status string
|
||||||
if err := tx.QueryRowContext(ctx, `SELECT status FROM gwf_organization_memberships WHERE organization_id=? AND user_id=?`, organizationID, userID).Scan(&status); err != nil {
|
if err := tx.QueryRowContext(ctx, `SELECT status FROM gwf_organization_memberships WHERE organization_id=? AND user_id=?`, organizationID, userID).Scan(&status); err != nil {
|
||||||
@@ -665,7 +806,7 @@ func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, o
|
|||||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
|
||||||
FROM gwf_access_bindings b
|
FROM gwf_access_bindings b
|
||||||
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
|
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
|
||||||
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
|
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
|
||||||
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
|
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
|
||||||
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
|
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
|
||||||
AND b.revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&otherActiveOwners); err != nil {
|
AND b.revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&otherActiveOwners); err != nil {
|
||||||
@@ -696,7 +837,7 @@ func (store *Store) Invitations(ctx context.Context, organizationID string, limi
|
|||||||
if !opaqueID(organizationID) || limit < 1 || limit > 1000 {
|
if !opaqueID(organizationID) || limit < 1 || limit > 1000 {
|
||||||
return nil, errors.New("authsqlite: invalid invitation query")
|
return nil, errors.New("authsqlite: invalid invitation query")
|
||||||
}
|
}
|
||||||
rows, err := store.db.QueryContext(ctx, `SELECT id,email_normalized,invited_by_user_id,direct_role,team_ids_json,created_at,expires_at,COALESCE(used_at,0),COALESCE(revoked_at,0) FROM gwf_organization_invitations WHERE organization_id=? ORDER BY created_at DESC LIMIT ?`, organizationID, limit)
|
rows, err := store.db.QueryContext(ctx, `SELECT id,email_normalized,invited_by_user_id,direct_role,team_ids_json,direct_roles_json,required_owner_role,created_at,expires_at,COALESCE(used_at,0),COALESCE(revoked_at,0) FROM gwf_organization_invitations WHERE organization_id=? ORDER BY created_at DESC,id LIMIT ?`, organizationID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -705,13 +846,16 @@ func (store *Store) Invitations(ctx context.Context, organizationID string, limi
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var value organizations.Invitation
|
var value organizations.Invitation
|
||||||
var created, expires, used, revoked int64
|
var created, expires, used, revoked int64
|
||||||
var teamIDs []byte
|
var teamIDs, rolesJSON []byte
|
||||||
if err = rows.Scan(&value.ID, &value.Email, &value.InvitedByUserID, &value.DirectRole, &teamIDs, &created, &expires, &used, &revoked); err != nil {
|
if err = rows.Scan(&value.ID, &value.Email, &value.InvitedByUserID, &value.DirectRole, &teamIDs, &rolesJSON, &value.RequiredOwnerRole, &created, &expires, &used, &revoked); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if json.Unmarshal(teamIDs, &value.TeamIDs) != nil || !validInvitationTeamIDs(value.TeamIDs) {
|
if json.Unmarshal(teamIDs, &value.TeamIDs) != nil || !validInvitationTeamIDs(value.TeamIDs) {
|
||||||
return nil, errors.New("authsqlite: stored invitation is invalid")
|
return nil, errors.New("authsqlite: stored invitation is invalid")
|
||||||
}
|
}
|
||||||
|
if !decodeInvitationRoles(&value, rolesJSON) {
|
||||||
|
return nil, errors.New("authsqlite: stored invitation roles are invalid")
|
||||||
|
}
|
||||||
value.OrganizationID = organizationID
|
value.OrganizationID = organizationID
|
||||||
value.CreatedAt, value.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
|
value.CreatedAt, value.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
|
||||||
if used != 0 {
|
if used != 0 {
|
||||||
@@ -742,6 +886,14 @@ func validInvitationTeamIDs(teamIDs []string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeInvitationRoles(invitation *organizations.Invitation, raw []byte) bool {
|
||||||
|
if len(raw) > 4096 || json.Unmarshal(raw, &invitation.DirectRoles) != nil || invitation.RequiredOwnerRole != "" && !safeName(invitation.RequiredOwnerRole) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := invitation.RoleNames()
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID string, teamIDs []string) error {
|
func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID string, teamIDs []string) error {
|
||||||
for _, teamID := range teamIDs {
|
for _, teamID := range teamIDs {
|
||||||
var count int
|
var count int
|
||||||
@@ -767,15 +919,23 @@ func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invita
|
|||||||
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
|
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var directRole string
|
var invitation organizations.Invitation
|
||||||
if err = tx.QueryRowContext(ctx, `SELECT direct_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&directRole); err != nil {
|
var rolesJSON []byte
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT direct_role,direct_roles_json,required_owner_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&invitation.DirectRole, &rolesJSON, &invitation.RequiredOwnerRole); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return organizations.ErrInvitationNotFound
|
return organizations.ErrInvitationNotFound
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ownerRole != "" && directRole == ownerRole {
|
if !decodeInvitationRoles(&invitation, rolesJSON) {
|
||||||
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, ownerRole)
|
return organizations.ErrInvitationNotFound
|
||||||
|
}
|
||||||
|
roles, _ := invitation.RoleNames()
|
||||||
|
if invitation.RequiredOwnerRole == "" && ownerRole != "" && slices.Contains(roles, ownerRole) {
|
||||||
|
invitation.RequiredOwnerRole = ownerRole
|
||||||
|
}
|
||||||
|
if invitation.RequiredOwnerRole != "" {
|
||||||
|
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, invitation.RequiredOwnerRole)
|
||||||
if ownerErr != nil {
|
if ownerErr != nil {
|
||||||
return ownerErr
|
return ownerErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gamertan.com/web/organizations"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ownedOperation struct {
|
||||||
|
name, action string
|
||||||
|
apply func(context.Context, roleSetFixture, string, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownedOperations() []ownedOperation {
|
||||||
|
return []ownedOperation{
|
||||||
|
{"profile", "organization.update", func(ctx context.Context, f roleSetFixture, actor, _ string) error {
|
||||||
|
_, err := f.organizations.UpdateOwnedOrganization(ctx, organizations.UpdateOrganization{ID: f.org.ID, Slug: "updated-business", Name: "Updated business", ActorUserID: actor, ExpectedRevision: 1, RequestID: "request-profile"})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{"suspend", "membership.suspended", func(ctx context.Context, f roleSetFixture, actor, target string) error {
|
||||||
|
return f.organizations.ChangeOwnedMembershipStatus(ctx, organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, ExpectedStatus: "active", Status: "suspended", RequestID: "request-status"})
|
||||||
|
}},
|
||||||
|
{"remove", "membership.remove", func(ctx context.Context, f roleSetFixture, actor, target string) error {
|
||||||
|
return f.organizations.RemoveOwnedMembershipIfCurrent(ctx, organizations.MembershipRemoval{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, ExpectedStatus: "active", RequestID: "request-remove"})
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedManagementRechecksActorInWriteTransaction(t *testing.T) {
|
||||||
|
for _, operation := range ownedOperations() {
|
||||||
|
for _, change := range []struct{ name, sql string }{
|
||||||
|
{"role revoked", `UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id='customer-12345'`},
|
||||||
|
{"role narrowed", `UPDATE gwf_access_bindings SET project_id=(SELECT id FROM gwf_projects LIMIT 1) WHERE subject_id='customer-12345'`},
|
||||||
|
{"actor suspended", `UPDATE gwf_organization_memberships SET status='suspended' WHERE user_id='customer-12345'`},
|
||||||
|
{"actor removed", `DELETE FROM gwf_organization_memberships WHERE user_id='customer-12345'`},
|
||||||
|
{"account disabled", `UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`},
|
||||||
|
{"registration incomplete", `UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`},
|
||||||
|
{"organization archived", `UPDATE gwf_organizations SET status='archived'`},
|
||||||
|
{"personal organization", `UPDATE gwf_organizations SET personal=1,personal_owner_user_id='customer-12345'`},
|
||||||
|
} {
|
||||||
|
t.Run(operation.name+"/"+change.name, func(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
f.addMember(t)
|
||||||
|
if change.name == "role narrowed" {
|
||||||
|
if _, err := f.organizations.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: f.org.ID, Slug: "project", Name: "Project"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Model a change committed after the caller displayed/authorized the
|
||||||
|
// operation. The repository must not rely on that earlier decision.
|
||||||
|
if _, err := f.store.db.Exec(change.sql); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := operation.apply(t.Context(), f, roleOwner, roleMember); err == nil {
|
||||||
|
t.Fatal("stale owner authority accepted")
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 0)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organizations WHERE id=? AND revision=1`, f.org.ID, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedManagementDoesNotInheritDelegatedAdministratorSemantics(t *testing.T) {
|
||||||
|
for _, operation := range ownedOperations() {
|
||||||
|
t.Run(operation.name, func(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
f.addMember(t)
|
||||||
|
if err := operation.apply(t.Context(), f, roleMember, roleMember); !errors.Is(err, organizations.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("non-owner management: %v", err)
|
||||||
|
}
|
||||||
|
if err := operation.apply(t.Context(), f, roleOwner, roleMember); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1)
|
||||||
|
if err := operation.apply(t.Context(), f, roleOwner, roleMember); err == nil {
|
||||||
|
t.Fatal("replayed mutation accepted")
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Legacy callers still authorize non-owner administrative operations in
|
||||||
|
// their application policy; the new explicit methods do not alter that API.
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
f.addMember(t)
|
||||||
|
err := f.organizations.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleMember, ExpectedStatus: "active", Status: "suspended"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delegated legacy operation changed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedMembershipPreservesLastOwnerAndRestoresSuspendedMember(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
f.addMember(t)
|
||||||
|
for _, operation := range ownedOperations()[1:] {
|
||||||
|
if err := operation.apply(t.Context(), f, roleOwner, roleOwner); !errors.Is(err, organizations.ErrLastOwner) {
|
||||||
|
t.Fatalf("%s last owner: %v", operation.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := ownedOperations()[1].apply(t.Context(), f, roleOwner, roleMember); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, ExpectedStatus: "suspended", Status: "active"}
|
||||||
|
if err := f.organizations.ChangeOwnedMembershipStatus(t.Context(), input); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.organizations.ChangeOwnedMembershipStatus(t.Context(), input); !errors.Is(err, organizations.ErrRevisionConflict) {
|
||||||
|
t.Fatalf("stale reactivation: %v", err)
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedManagementRollsBackWithAuditFailure(t *testing.T) {
|
||||||
|
for _, operation := range ownedOperations() {
|
||||||
|
t.Run(operation.name, func(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
f.addMember(t)
|
||||||
|
_, pending := f.invite(t, "buyer")
|
||||||
|
team, err := f.organizations.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: f.org.ID, Slug: "team", Name: "Team", ActorUserID: roleOwner})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = f.organizations.AddTeamMember(t.Context(), team.ID, roleMember, roleOwner); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = f.store.db.Exec(`CREATE TRIGGER reject_owned_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='` + operation.action + `' BEGIN SELECT RAISE(ABORT,'injected audit failure'); END`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = operation.apply(t.Context(), f, roleOwner, roleMember); err == nil {
|
||||||
|
t.Fatal("audit failure accepted")
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, roleMember, 1)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND revoked_at IS NULL`, roleMember, 1)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organizations WHERE id=? AND revision=1`, f.org.ID, 1)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NULL`, pending.ID, 1)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentOwnedManagementHasOneWinner(t *testing.T) {
|
||||||
|
for _, operation := range ownedOperations() {
|
||||||
|
t.Run(operation.name, func(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
f.addMember(t)
|
||||||
|
start, results := make(chan struct{}), make(chan error, 2)
|
||||||
|
for range 2 {
|
||||||
|
go func() { <-start; results <- operation.apply(t.Context(), f, roleOwner, roleMember) }()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
success, stale := 0, 0
|
||||||
|
for range 2 {
|
||||||
|
err := <-results
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
success++
|
||||||
|
case errors.Is(err, organizations.ErrRevisionConflict), errors.Is(err, organizations.ErrMembershipNotFound):
|
||||||
|
stale++
|
||||||
|
default:
|
||||||
|
t.Fatalf("concurrent mutation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success != 1 || stale != 1 {
|
||||||
|
t.Fatalf("success=%d stale=%d", success, stale)
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ auth.OwnProfileRepository = (*Store)(nil)
|
||||||
|
|
||||||
|
const ownProfileQuery = `SELECT u.id,u.username,u.email,u.display_name,u.profile_revision
|
||||||
|
FROM gwf_users u JOIN gwf_auth_sessions s ON s.user_id=u.id
|
||||||
|
WHERE s.token_hash=? AND s.expires_at>? AND u.status='active'
|
||||||
|
AND u.registration_pending=0 AND u.password_change_required=0`
|
||||||
|
|
||||||
|
func scanOwnProfile(row interface{ Scan(...any) error }) (auth.OwnProfile, error) {
|
||||||
|
var profile auth.OwnProfile
|
||||||
|
err := row.Scan(&profile.UserID, &profile.Username, &profile.Email, &profile.DisplayName, &profile.Revision)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return auth.OwnProfile{}, auth.ErrProfileAccess
|
||||||
|
}
|
||||||
|
return profile, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) OwnProfile(ctx context.Context, session [32]byte, now time.Time) (auth.OwnProfile, error) {
|
||||||
|
if zeroDigest(session) || now.IsZero() {
|
||||||
|
return auth.OwnProfile{}, auth.ErrProfileAccess
|
||||||
|
}
|
||||||
|
return scanOwnProfile(store.db.QueryRowContext(ctx, ownProfileQuery, session[:], now.Unix()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) UpdateOwnProfile(ctx context.Context, change auth.ProfileEdit, audit auth.AuditEvent) (auth.OwnProfile, error) {
|
||||||
|
value, err := auth.NormalizeProfileValue(change.Field, change.Value)
|
||||||
|
if err != nil || !opaqueID(change.UserID) || zeroDigest(change.SessionDigest) || change.ExpectedRevision < 1 || change.ExpectedRevision == math.MaxInt64 ||
|
||||||
|
!validAuditEvent(audit) || audit.ActorUserID != change.UserID || audit.ResourceType != "user" || audit.ResourceID != change.UserID || audit.Action != "auth.profile."+change.Field ||
|
||||||
|
change.ExpectedPasswordHash != "" && (change.Field != "username" || len(change.ExpectedPasswordHash) > 1024) {
|
||||||
|
return auth.OwnProfile{}, auth.ErrProfileInput
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return auth.OwnProfile{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
// This first statement takes the writer lock and tests the real current
|
||||||
|
// session/account/revision together. No read-before-write lock upgrade race.
|
||||||
|
set := `display_name=?`
|
||||||
|
args := []any{value}
|
||||||
|
if change.Field == "username" {
|
||||||
|
set = `username=?,username_normalized=?`
|
||||||
|
args = append(args, normalize(value))
|
||||||
|
}
|
||||||
|
args = append(args, audit.CreatedAt.Unix(), change.UserID, change.ExpectedRevision, change.SessionDigest[:], audit.CreatedAt.Unix(), change.ExpectedPasswordHash, change.ExpectedPasswordHash)
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET `+set+`,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?)
|
||||||
|
WHERE id=? AND profile_revision=? AND status='active' AND registration_pending=0 AND password_change_required=0
|
||||||
|
AND EXISTS (SELECT 1 FROM gwf_auth_sessions WHERE user_id=gwf_users.id AND token_hash=? AND expires_at>?)
|
||||||
|
AND (?='' OR EXISTS (SELECT 1 FROM gwf_password_credentials WHERE user_id=gwf_users.id AND password_hash=?))`, args...)
|
||||||
|
if err != nil {
|
||||||
|
var constraint *sqlite.Error
|
||||||
|
if errors.As(err, &constraint) && constraint.Code() == 2067 {
|
||||||
|
return auth.OwnProfile{}, auth.ErrUsernameUnavailable
|
||||||
|
}
|
||||||
|
return auth.OwnProfile{}, err
|
||||||
|
}
|
||||||
|
changed, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return auth.OwnProfile{}, err
|
||||||
|
}
|
||||||
|
if changed != 1 {
|
||||||
|
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
|
||||||
|
if err != nil {
|
||||||
|
return auth.OwnProfile{}, err
|
||||||
|
}
|
||||||
|
if profile.UserID != change.UserID {
|
||||||
|
return auth.OwnProfile{}, auth.ErrProfileAccess
|
||||||
|
}
|
||||||
|
return auth.OwnProfile{}, auth.ErrProfileConflict
|
||||||
|
}
|
||||||
|
if change.Field == "username" {
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=? AND token_hash<>?`, change.UserID, change.SessionDigest[:]); err != nil {
|
||||||
|
return auth.OwnProfile{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, audit); err != nil {
|
||||||
|
return auth.OwnProfile{}, err
|
||||||
|
}
|
||||||
|
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
|
||||||
|
if err != nil {
|
||||||
|
return auth.OwnProfile{}, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return auth.OwnProfile{}, err
|
||||||
|
}
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type profileFixture struct {
|
||||||
|
store *Store
|
||||||
|
path string
|
||||||
|
now time.Time
|
||||||
|
user auth.User
|
||||||
|
session, other auth.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProfileFixture(t *testing.T) profileFixture {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "identity.sqlite")
|
||||||
|
store, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
user := auth.User{ID: "profile-user", Username: "profile.reader", Email: "profile@example.test", DisplayName: "Profile Reader", Status: "active", CreatedAt: now, UpdatedAt: now}
|
||||||
|
if err = store.CreateUser(t.Context(), user, "fixture-hash"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-session")), CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour)}
|
||||||
|
other := session
|
||||||
|
other.Digest = sha256.Sum256([]byte("other-session"))
|
||||||
|
for _, s := range []auth.Session{session, other} {
|
||||||
|
if err = store.CreateSession(t.Context(), s); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profileFixture{store, path, now, user, session, other}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f profileFixture) change(field, value string, revision int64) (auth.ProfileEdit, auth.AuditEvent) {
|
||||||
|
return auth.ProfileEdit{UserID: f.user.ID, SessionDigest: f.session.Digest, ExpectedRevision: revision, Field: field, Value: value},
|
||||||
|
auth.AuditEvent{ID: "profile-audit-" + field, ActorUserID: f.user.ID, Action: "auth.profile." + field, ResourceType: "user", ResourceID: f.user.ID, Summary: "Own profile field changed", CreatedAt: f.now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnProfileStableIdentityAndSessionPolicy(t *testing.T) {
|
||||||
|
f := newProfileFixture(t)
|
||||||
|
initial, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now)
|
||||||
|
if err != nil || initial.Revision != 1 {
|
||||||
|
t.Fatalf("initial revision: %d %v", initial.Revision, err)
|
||||||
|
}
|
||||||
|
change, audit := f.change("display_name", " Émilie ★ ", 1)
|
||||||
|
updated, err := f.store.UpdateOwnProfile(t.Context(), change, audit)
|
||||||
|
if err != nil || updated.DisplayName != "Émilie ★" || updated.Revision != 2 || updated.UserID != initial.UserID || updated.Username != initial.Username || updated.Email != initial.Email {
|
||||||
|
t.Fatalf("display update: %+v %v", updated, err)
|
||||||
|
}
|
||||||
|
if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil {
|
||||||
|
t.Fatal("display edit revoked session", err)
|
||||||
|
}
|
||||||
|
if _, err = f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) {
|
||||||
|
t.Fatalf("stale: %v", err)
|
||||||
|
}
|
||||||
|
change, audit = f.change("username", "new.reader", 2)
|
||||||
|
change.ExpectedPasswordHash = "fixture-hash"
|
||||||
|
updated, err = f.store.UpdateOwnProfile(t.Context(), change, audit)
|
||||||
|
if err != nil || updated.Username != "new.reader" || updated.Revision != 3 || updated.UserID != initial.UserID || updated.Email != initial.Email {
|
||||||
|
t.Fatalf("username update: %+v %v", updated, err)
|
||||||
|
}
|
||||||
|
if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); !errors.Is(err, auth.ErrProfileAccess) {
|
||||||
|
t.Fatalf("other session survived: %v", err)
|
||||||
|
}
|
||||||
|
user, hash, err := f.store.CredentialByIdentifier(t.Context(), "NEW.READER")
|
||||||
|
if err != nil || user.ID != initial.UserID || hash != "fixture-hash" {
|
||||||
|
t.Fatal("credential identity changed", err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err = f.store.db.QueryRow(`SELECT count(*) FROM gwf_audit_events WHERE actor_user_id=? AND resource_id=?`, f.user.ID, f.user.ID).Scan(&count); err != nil || count != 2 {
|
||||||
|
t.Fatalf("audits: %d %v", count, err)
|
||||||
|
}
|
||||||
|
reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer reopened.Close()
|
||||||
|
if recovered, err := reopened.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || recovered != updated {
|
||||||
|
t.Fatalf("restart: %+v %v", recovered, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnProfileAuthorizationAndRollback(t *testing.T) {
|
||||||
|
for _, test := range []struct{ name, sql string }{
|
||||||
|
{"revoked-session", `DELETE FROM gwf_auth_sessions`},
|
||||||
|
{"expired-session", `UPDATE gwf_auth_sessions SET expires_at=1`},
|
||||||
|
{"suspended", `UPDATE gwf_users SET status='suspended'`},
|
||||||
|
{"disabled", `UPDATE gwf_users SET status='disabled'`},
|
||||||
|
{"registration-pending", `UPDATE gwf_users SET registration_pending=1`},
|
||||||
|
{"password-change", `UPDATE gwf_users SET password_change_required=1`},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
f := newProfileFixture(t)
|
||||||
|
if _, err := f.store.db.Exec(test.sql); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
change, audit := f.change("display_name", "not allowed", 1)
|
||||||
|
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) {
|
||||||
|
t.Fatalf("access: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
f := newProfileFixture(t)
|
||||||
|
change, audit := f.change("username", "new.reader", 1)
|
||||||
|
change.UserID = "another-user"
|
||||||
|
audit.ActorUserID = change.UserID
|
||||||
|
audit.ResourceID = change.UserID
|
||||||
|
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) {
|
||||||
|
t.Fatalf("foreign user: %v", err)
|
||||||
|
}
|
||||||
|
change, audit = f.change("username", "new.reader", 1)
|
||||||
|
change.ExpectedPasswordHash = "old-verified-hash"
|
||||||
|
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) {
|
||||||
|
t.Fatalf("changed password: %v", err)
|
||||||
|
}
|
||||||
|
change.ExpectedPasswordHash = "fixture-hash"
|
||||||
|
if err := f.store.AppendAudit(t.Context(), audit); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); err == nil {
|
||||||
|
t.Fatal("duplicate audit accepted")
|
||||||
|
}
|
||||||
|
if profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || profile.Revision != 1 || profile.Username != f.user.Username {
|
||||||
|
t.Fatalf("rollback: %+v %v", profile, err)
|
||||||
|
}
|
||||||
|
if _, err := f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil {
|
||||||
|
t.Fatal("audit failure revoked session", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnProfileUniquenessConcurrencyAndMigration(t *testing.T) {
|
||||||
|
f := newProfileFixture(t)
|
||||||
|
otherUser := f.user
|
||||||
|
otherUser.ID = "another-user"
|
||||||
|
otherUser.Username = "another.reader"
|
||||||
|
otherUser.Email = "another@example.test"
|
||||||
|
if err := f.store.CreateUser(t.Context(), otherUser, "fixture-hash"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
change, audit := f.change("username", "ANOTHER.READER", 1)
|
||||||
|
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrUsernameUnavailable) {
|
||||||
|
t.Fatalf("unique name: %v", err)
|
||||||
|
}
|
||||||
|
second, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer second.Close()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make(chan error, 2)
|
||||||
|
for _, store := range []*Store{f.store, second} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(store *Store) {
|
||||||
|
defer wg.Done()
|
||||||
|
change, audit := f.change("display_name", "New Name", 1)
|
||||||
|
_, err := store.UpdateOwnProfile(t.Context(), change, audit)
|
||||||
|
results <- err
|
||||||
|
}(store)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
success, conflict := 0, 0
|
||||||
|
for err := range results {
|
||||||
|
if err == nil {
|
||||||
|
success++
|
||||||
|
} else if errors.Is(err, auth.ErrProfileConflict) {
|
||||||
|
conflict++
|
||||||
|
} else {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success != 1 || conflict != 1 {
|
||||||
|
t.Fatalf("concurrent writes: %d successes, %d conflicts", success, conflict)
|
||||||
|
}
|
||||||
|
// Recreate the actual previous schema without rewriting its identity rows.
|
||||||
|
if _, err = f.store.db.Exec(`ALTER TABLE gwf_users DROP COLUMN profile_revision`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = f.store.db.Exec(`DELETE FROM gamertan_web_migrations WHERE version=11`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if version, err := f.store.CurrentSchema(t.Context()); err != nil || version != 10 {
|
||||||
|
t.Fatalf("prior schema: %d %v", version, err)
|
||||||
|
}
|
||||||
|
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
|
||||||
|
t.Fatal("startup accepted old schema")
|
||||||
|
}
|
||||||
|
if err = f.store.Migrate(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now)
|
||||||
|
if err != nil || profile.UserID != f.user.ID || profile.Email != f.user.Email || profile.DisplayName != "New Name" || profile.Revision != 1 {
|
||||||
|
t.Fatalf("migration: %+v %v", profile, err)
|
||||||
|
}
|
||||||
|
if err = f.store.Migrate(t.Context()); err != nil {
|
||||||
|
t.Fatal("idempotent migration", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
"gamertan.com/web/organizations"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roleSetFixture struct {
|
||||||
|
store *Store
|
||||||
|
access *access.Service
|
||||||
|
organizations *organizations.Service
|
||||||
|
org organizations.Organization
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleOwner = "customer-12345"
|
||||||
|
const roleMember = "member-12345678"
|
||||||
|
|
||||||
|
func newRoleSetFixture(t *testing.T) roleSetFixture {
|
||||||
|
t.Helper()
|
||||||
|
store, _, policy, input := ownedOrganizationFixture(t)
|
||||||
|
policy.Roles["buyer"] = "Buyer"
|
||||||
|
policy.Roles["billing"] = "Billing manager"
|
||||||
|
policy.Roles["member"] = "Member"
|
||||||
|
policy.Permissions["billing.manage"] = "Manage billing"
|
||||||
|
policy.Grants["buyer"] = []string{"customer.purchase"}
|
||||||
|
policy.Grants["billing"] = []string{"billing.manage"}
|
||||||
|
policy.Grants["member"] = nil
|
||||||
|
now := time.Unix(2100, 0).UTC()
|
||||||
|
accessService, err := access.New(store, policy, access.Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = accessService.Seed(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service, err := organizations.New(store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true, Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
org, err := service.CreateOwnedOrganization(t.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,registration_pending,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,'active',0,2000,2000)`, roleMember, "member", "member", "member@example.test", "member@example.test", "Member"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return roleSetFixture{store: store, access: accessService, organizations: service, org: org, now: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f roleSetFixture) invite(t *testing.T, roles ...string) (string, organizations.Invitation) {
|
||||||
|
t.Helper()
|
||||||
|
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRoles: roles, Lifetime: time.Hour, RequestID: "request-invite"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return raw, invitation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f roleSetFixture) addMember(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
raw, _ := f.invite(t, "member")
|
||||||
|
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f roleSetFixture) bindings(t *testing.T, user string) ([]string, []string) {
|
||||||
|
t.Helper()
|
||||||
|
bindings, err := f.access.OrganizationUserBindings(t.Context(), f.org.ID, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var ids, roles []string
|
||||||
|
for _, binding := range bindings {
|
||||||
|
if binding.SubjectID == user {
|
||||||
|
ids = append(ids, binding.ID)
|
||||||
|
roles = append(roles, binding.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(ids)
|
||||||
|
slices.Sort(roles)
|
||||||
|
return ids, roles
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f roleSetFixture) change(t *testing.T, actor, target string, roles ...string) error {
|
||||||
|
t.Helper()
|
||||||
|
ids, _ := f.bindings(t, target)
|
||||||
|
_, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, RequestID: "request-roles", Roles: roles, ExpectedBindingIDs: ids})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleSetCombinesCapabilitiesWithoutNarrowGrantChanges(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
f.addMember(t)
|
||||||
|
project, err := f.organizations.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: f.org.ID, Slug: "project", Name: "Project"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
narrow, err := f.access.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: roleMember, Role: "member", Scope: access.Scope{OrganizationID: f.org.ID, ProjectID: project.ID}, GrantedBy: roleOwner})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = f.change(t, roleOwner, roleMember, "buyer", "billing"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, roles := f.bindings(t, roleMember)
|
||||||
|
if !slices.Equal(roles, []string{"billing", "buyer"}) {
|
||||||
|
t.Fatalf("roles=%v", roles)
|
||||||
|
}
|
||||||
|
for _, permission := range []string{"customer.purchase", "billing.manage"} {
|
||||||
|
decision, err := f.access.Authorize(t.Context(), roleMember, access.Scope{OrganizationID: f.org.ID}, permission)
|
||||||
|
if err != nil || !decision.Allowed {
|
||||||
|
t.Fatalf("permission=%s allowed=%v err=%v", permission, decision.Allowed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=? AND revoked_at IS NULL`, narrow.ID, 1)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1)
|
||||||
|
if err = f.change(t, roleOwner, roleMember, "billing", "buyer"); !errors.Is(err, access.ErrRoleUnchanged) {
|
||||||
|
t.Fatalf("unchanged=%v", err)
|
||||||
|
}
|
||||||
|
if err = f.change(t, roleMember, roleMember, "member"); !errors.Is(err, access.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("non-owner bulk change=%v", err)
|
||||||
|
}
|
||||||
|
if err = f.change(t, roleOwner, roleOwner, "billing", "buyer"); !errors.Is(err, access.ErrLastOwner) {
|
||||||
|
t.Fatalf("last owner=%v", err)
|
||||||
|
}
|
||||||
|
if err = f.change(t, roleOwner, roleMember, "customer.owner", "billing"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = f.change(t, roleMember, roleOwner, "buyer"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = f.change(t, roleMember, roleMember, "buyer"); !errors.Is(err, access.ErrLastOwner) {
|
||||||
|
t.Fatalf("new last owner=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleSetConcurrentChangesHaveOneWinner(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
f.addMember(t)
|
||||||
|
ids, _ := f.bindings(t, roleMember)
|
||||||
|
start := make(chan struct{})
|
||||||
|
results := make(chan error, 2)
|
||||||
|
for range 2 {
|
||||||
|
go func() {
|
||||||
|
<-start
|
||||||
|
_, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, Roles: []string{"buyer", "billing"}, ExpectedBindingIDs: ids})
|
||||||
|
results <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
success, conflict := 0, 0
|
||||||
|
for range 2 {
|
||||||
|
err := <-results
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
success++
|
||||||
|
case errors.Is(err, access.ErrRoleChangeConflict):
|
||||||
|
conflict++
|
||||||
|
default:
|
||||||
|
t.Fatalf("concurrent error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success != 1 || conflict != 1 {
|
||||||
|
t.Fatalf("success=%d conflict=%d", success, conflict)
|
||||||
|
}
|
||||||
|
_, roles := f.bindings(t, roleMember)
|
||||||
|
if !slices.Equal(roles, []string{"billing", "buyer"}) {
|
||||||
|
t.Fatalf("roles=%v", roles)
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleSetRollsBackRevocationAndPartialInsert(t *testing.T) {
|
||||||
|
for _, stage := range []string{"second binding", "audit", "missing role"} {
|
||||||
|
t.Run(stage, func(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
f.addMember(t)
|
||||||
|
before, _ := f.bindings(t, roleMember)
|
||||||
|
var statement string
|
||||||
|
switch stage {
|
||||||
|
case "second binding":
|
||||||
|
statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'write failure'); END`
|
||||||
|
case "audit":
|
||||||
|
statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='access.role.replace' BEGIN SELECT RAISE(ABORT,'audit failure'); END`
|
||||||
|
case "missing role":
|
||||||
|
statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'`
|
||||||
|
}
|
||||||
|
if _, err := f.store.db.Exec(statement); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.change(t, roleOwner, roleMember, "billing", "buyer"); err == nil {
|
||||||
|
t.Fatal("write failure accepted")
|
||||||
|
}
|
||||||
|
after, roles := f.bindings(t, roleMember)
|
||||||
|
if !slices.Equal(before, after) || !slices.Equal(roles, []string{"member"}) {
|
||||||
|
t.Fatalf("after=%v roles=%v", after, roles)
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationPreservesRolesAuthorityAndSingleUse(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
raw, invitation := f.invite(t, "buyer", "billing")
|
||||||
|
stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.RequiredOwnerRole != "customer.owner" || stored.DirectRole != "" || !slices.Equal(stored.DirectRoles, []string{"billing", "buyer"}) {
|
||||||
|
t.Fatalf("roles=%v authority=%q", stored.DirectRoles, stored.RequiredOwnerRole)
|
||||||
|
}
|
||||||
|
listed, err := f.organizations.Invitations(t.Context(), f.org.ID, 10)
|
||||||
|
if err != nil || len(listed) != 1 || !slices.Equal(listed[0].DirectRoles, stored.DirectRoles) {
|
||||||
|
t.Fatalf("listed=%v err=%v", listed, err)
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.create' AND request_id='request-invite'`, invitation.ID, 1)
|
||||||
|
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleOwner); err == nil {
|
||||||
|
t.Fatal("wrong email accepted")
|
||||||
|
}
|
||||||
|
start := make(chan struct{})
|
||||||
|
results := make(chan error, 2)
|
||||||
|
for range 2 {
|
||||||
|
go func() { <-start; results <- f.organizations.AcceptInvitation(t.Context(), raw, roleMember) }()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
success := 0
|
||||||
|
for range 2 {
|
||||||
|
if err := <-results; err == nil {
|
||||||
|
success++
|
||||||
|
} else if !errors.Is(err, organizations.ErrInvitationNotFound) {
|
||||||
|
t.Fatalf("accept error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success != 1 {
|
||||||
|
t.Fatalf("accepted=%d", success)
|
||||||
|
}
|
||||||
|
_, roles := f.bindings(t, roleMember)
|
||||||
|
if !slices.Equal(roles, []string{"billing", "buyer"}) {
|
||||||
|
t.Fatalf("roles=%v", roles)
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 1)
|
||||||
|
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
|
||||||
|
t.Fatalf("replay=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationRechecksGrantorAndRecipient(t *testing.T) {
|
||||||
|
for _, mutation := range []string{
|
||||||
|
`UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id='customer-12345'`,
|
||||||
|
`UPDATE gwf_organization_memberships SET status='suspended' WHERE user_id='customer-12345'`,
|
||||||
|
`UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`,
|
||||||
|
`UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`,
|
||||||
|
`DELETE FROM gwf_organization_memberships WHERE user_id='customer-12345'`,
|
||||||
|
`UPDATE gwf_users SET status='disabled' WHERE id='member-12345678'`,
|
||||||
|
`UPDATE gwf_users SET registration_pending=1 WHERE id='member-12345678'`,
|
||||||
|
`UPDATE gwf_organizations SET status='archived'`,
|
||||||
|
`UPDATE gwf_organization_invitations SET expires_at=2100`,
|
||||||
|
`UPDATE gwf_organization_invitations SET revoked_at=2100`,
|
||||||
|
`UPDATE gwf_organization_invitations SET direct_roles_json='["buyer","buyer"]'`,
|
||||||
|
} {
|
||||||
|
t.Run(mutation, func(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
raw, invitation := f.invite(t, "buyer", "billing")
|
||||||
|
if _, err := f.store.db.Exec(mutation); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Stored authority still applies through a differently configured service.
|
||||||
|
other, err := organizations.New(f.store, organizations.Options{Now: func() time.Time { return f.now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = other.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
|
||||||
|
t.Fatal("stale or invalid authority accepted")
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationRejectsImplicitReactivationAndNonOwnerManagement(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
f.addMember(t)
|
||||||
|
if _, _, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "new@example.test", InvitedByUserID: roleMember, DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("member invite=%v", err)
|
||||||
|
}
|
||||||
|
raw, invitation := f.invite(t, "buyer", "billing")
|
||||||
|
if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleMember, "request-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("member revoke=%v", err)
|
||||||
|
}
|
||||||
|
if err := f.organizations.SetMembershipStatus(t.Context(), f.org.ID, roleMember, "suspended", roleOwner, "request-suspend"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
|
||||||
|
t.Fatal("invitation reactivated suspended member")
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='suspended'`, roleMember, 1)
|
||||||
|
if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleOwner, "request-revoke-owner"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationAcceptanceRollsBackEveryWrite(t *testing.T) {
|
||||||
|
for _, stage := range []string{"membership", "binding", "audit", "missing role"} {
|
||||||
|
t.Run(stage, func(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
raw, invitation := f.invite(t, "buyer", "billing")
|
||||||
|
var statement string
|
||||||
|
switch stage {
|
||||||
|
case "membership":
|
||||||
|
statement = `CREATE TRIGGER fail_member BEFORE INSERT ON gwf_organization_memberships BEGIN SELECT RAISE(ABORT,'membership failure'); END`
|
||||||
|
case "binding":
|
||||||
|
statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'binding failure'); END`
|
||||||
|
case "audit":
|
||||||
|
statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.accept' BEGIN SELECT RAISE(ABORT,'audit failure'); END`
|
||||||
|
case "missing role":
|
||||||
|
statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'`
|
||||||
|
}
|
||||||
|
if _, err := f.store.db.Exec(statement); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
|
||||||
|
t.Fatal("partial acceptance succeeded")
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationMigrationPreservesLegacyAndRequiresExplicitMigration(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRole: "customer.owner", Lifetime: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Reconstruct the previous invitation schema in this disposable database.
|
||||||
|
if _, err = f.store.db.Exec(`ALTER TABLE gwf_organization_invitations DROP COLUMN direct_roles_json;
|
||||||
|
ALTER TABLE gwf_organization_invitations DROP COLUMN required_owner_role;
|
||||||
|
ALTER TABLE gwf_users DROP COLUMN profile_revision;
|
||||||
|
DELETE FROM gamertan_web_migrations WHERE version>=10`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
|
||||||
|
t.Fatal("old schema accepted without migration")
|
||||||
|
}
|
||||||
|
for range 2 {
|
||||||
|
if err = f.store.Migrate(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = f.store.RequireCurrentSchema(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now)
|
||||||
|
if err != nil || stored.DirectRole != "customer.owner" || len(stored.DirectRoles) != 0 || stored.RequiredOwnerRole != "" {
|
||||||
|
t.Fatalf("legacy changed: %+v err=%v", stored, err)
|
||||||
|
}
|
||||||
|
if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id=?`, roleOwner); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("legacy owner authority=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=NULL WHERE subject_id=?`, roleOwner); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids, roles := f.bindings(t, roleMember)
|
||||||
|
if !slices.Equal(roles, []string{"customer.owner"}) || !slices.Equal(ids, []string{"invite-" + invitation.ID}) {
|
||||||
|
t.Fatalf("legacy IDs=%v roles=%v", ids, roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationUsesAdvancingClockAndBoundAudit(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
service, err := organizations.New(f.store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, invitation, err := service.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: []string{"billing", "buyer"}, Lifetime: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
audit := organizations.AuditEvent{ID: "audit-accept-12345", OrganizationID: f.org.ID, ActorUserID: roleMember, Action: "invitation.accept", ResourceType: "invitation", ResourceID: invitation.ID, Summary: "Invitation accepted", CreatedAt: time.Now().UTC()}
|
||||||
|
for _, field := range []string{"actor", "resource", "action"} {
|
||||||
|
bad := audit
|
||||||
|
switch field {
|
||||||
|
case "actor":
|
||||||
|
bad.ActorUserID = roleOwner
|
||||||
|
case "resource":
|
||||||
|
bad.ResourceID = "invitation-other"
|
||||||
|
case "action":
|
||||||
|
bad.Action = "invitation.create"
|
||||||
|
}
|
||||||
|
if err = f.store.AcceptInvitationWithRoles(t.Context(), invitation.Digest, roleMember, "customer.owner", time.Now().UTC(), bad); err == nil {
|
||||||
|
t.Fatalf("mismatched audit %s accepted", field)
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
|
||||||
|
}
|
||||||
|
if err = service.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
|
||||||
|
t.Fatalf("real clock acceptance=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationCreationIsAtomicAndRejectsUnknownRole(t *testing.T) {
|
||||||
|
for _, fail := range []string{"unknown role", "audit"} {
|
||||||
|
t.Run(fail, func(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
roles := []string{"billing", "buyer"}
|
||||||
|
if fail == "unknown role" {
|
||||||
|
roles = append(roles, "unknown")
|
||||||
|
} else if _, err := f.store.db.Exec(`CREATE TRIGGER fail_invite BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.create' BEGIN SELECT RAISE(ABORT,'audit failure'); END`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: roles, Lifetime: time.Hour})
|
||||||
|
if err == nil || raw != "" || invitation.ID != "" {
|
||||||
|
t.Fatal("failed creation returned invitation")
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE organization_id=?`, f.org.ID, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationCannotBypassMembershipChanges(t *testing.T) {
|
||||||
|
for _, optimistic := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "legacy removal", true: "optimistic removal"}[optimistic], func(t *testing.T) {
|
||||||
|
f := newRoleSetFixture(t)
|
||||||
|
oldToken, oldInvitation := f.invite(t, "buyer", "billing")
|
||||||
|
f.addMember(t)
|
||||||
|
if err := f.organizations.AcceptInvitation(t.Context(), oldToken, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
|
||||||
|
t.Fatalf("old invite elevated existing member=%v", err)
|
||||||
|
}
|
||||||
|
_, roles := f.bindings(t, roleMember)
|
||||||
|
if !slices.Equal(roles, []string{"member"}) {
|
||||||
|
t.Fatalf("roles changed=%v", roles)
|
||||||
|
}
|
||||||
|
remove := func() error {
|
||||||
|
if optimistic {
|
||||||
|
return f.organizations.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, ExpectedStatus: "active", RequestID: "request-remove"})
|
||||||
|
}
|
||||||
|
return f.organizations.RemoveMembership(t.Context(), f.org.ID, roleMember, roleOwner, "request-remove")
|
||||||
|
}
|
||||||
|
if _, err := f.store.db.Exec(`CREATE TRIGGER fail_removal BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='membership.remove' BEGIN SELECT RAISE(ABORT,'audit failure'); END`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := remove(); err == nil {
|
||||||
|
t.Fatal("unaudited removal succeeded")
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 1)
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NULL AND used_at IS NULL`, oldInvitation.ID, 1)
|
||||||
|
if _, err := f.store.db.Exec(`DROP TRIGGER fail_removal`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := remove(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NOT NULL AND used_at IS NULL`, oldInvitation.ID, 1)
|
||||||
|
if err := f.organizations.AcceptInvitation(t.Context(), oldToken, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
|
||||||
|
t.Fatalf("old invite restored removed member=%v", err)
|
||||||
|
}
|
||||||
|
newToken, _ := f.invite(t, "buyer")
|
||||||
|
if err := f.organizations.AcceptInvitation(t.Context(), newToken, roleMember); err != nil {
|
||||||
|
t.Fatalf("intentional fresh invitation=%v", err)
|
||||||
|
}
|
||||||
|
_, roles = f.bindings(t, roleMember)
|
||||||
|
if !slices.Equal(roles, []string{"buyer"}) {
|
||||||
|
t.Fatalf("fresh roles=%v", roles)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
|
|||||||
return store, nil
|
return store, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const SchemaVersion = 9
|
const SchemaVersion = 11
|
||||||
|
|
||||||
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
|
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
|
||||||
var exists int
|
var exists int
|
||||||
@@ -179,6 +179,7 @@ func (store *Store) Migrate(ctx context.Context) error {
|
|||||||
table, column, definition string
|
table, column, definition string
|
||||||
}{
|
}{
|
||||||
{"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`},
|
{"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`},
|
||||||
|
{"gwf_users", "profile_revision", `INTEGER NOT NULL DEFAULT 1 CHECK(profile_revision > 0)`},
|
||||||
{"gwf_organizations", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`},
|
{"gwf_organizations", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`},
|
||||||
{"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
|
{"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
|
||||||
{"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 0`},
|
{"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 0`},
|
||||||
@@ -189,6 +190,8 @@ func (store *Store) Migrate(ctx context.Context) error {
|
|||||||
{"gwf_organization_invitations", "revoked_at", `INTEGER`},
|
{"gwf_organization_invitations", "revoked_at", `INTEGER`},
|
||||||
{"gwf_organization_invitations", "direct_role", `TEXT NOT NULL DEFAULT ''`},
|
{"gwf_organization_invitations", "direct_role", `TEXT NOT NULL DEFAULT ''`},
|
||||||
{"gwf_organization_invitations", "team_ids_json", `BLOB NOT NULL DEFAULT '[]'`},
|
{"gwf_organization_invitations", "team_ids_json", `BLOB NOT NULL DEFAULT '[]'`},
|
||||||
|
{"gwf_organization_invitations", "direct_roles_json", `BLOB NOT NULL DEFAULT '[]'`},
|
||||||
|
{"gwf_organization_invitations", "required_owner_role", `TEXT NOT NULL DEFAULT ''`},
|
||||||
} {
|
} {
|
||||||
exists, columnErr := sqliteColumnExists(ctx, tx, migration.table, migration.column)
|
exists, columnErr := sqliteColumnExists(ctx, tx, migration.table, migration.column)
|
||||||
if columnErr != nil {
|
if columnErr != nil {
|
||||||
@@ -239,6 +242,12 @@ func (store *Store) Migrate(ctx context.Context) error {
|
|||||||
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(9,?)`, time.Now().UTC().Unix()); err != nil {
|
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(9,?)`, time.Now().UTC().Unix()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(10,?)`, time.Now().UTC().Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(11,?)`, time.Now().UTC().Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,26 @@ template. Its private evidence, persistent bans, account data, route policy,
|
|||||||
operator exclusions, synchronization, and publishing workflow remain
|
operator exclusions, synchronization, and publishing workflow remain
|
||||||
application-owned. Useful pressure from that migration may improve a general
|
application-owned. Useful pressure from that migration may improve a general
|
||||||
interface, but it may not smuggle EQL-specific policy into this module.
|
interface, but it may not smuggle EQL-specific policy into this module.
|
||||||
|
|
||||||
|
## Optional personal-profile editing
|
||||||
|
|
||||||
|
`auth.OwnProfileRepository` supports a narrow self-service boundary independently
|
||||||
|
of instance-directory authorization. Load the profile using the current session
|
||||||
|
digest; derive the target from that result. Normalize one username or display
|
||||||
|
name using `auth.NormalizeProfileValue`. Never decode an HTTP body directly into
|
||||||
|
`auth.ProfileEdit`, which carries trusted identity and credential-check state.
|
||||||
|
|
||||||
|
Require CSRF/origin validation for browser writes and rate-limit credential work.
|
||||||
|
For username changes, verify the current password (supply its hash as
|
||||||
|
`ExpectedPasswordHash`) or consume an exact operation-bound passkey approval;
|
||||||
|
enforce any additional authentication policy your application requires. Include
|
||||||
|
the session, user, value and expected profile revision in the passkey binding.
|
||||||
|
The SQLite transaction rechecks session/account/revision and any verified hash,
|
||||||
|
updates one field, revokes other sessions for username edits, and appends audit.
|
||||||
|
Do not log the command or include secret material in its audit.
|
||||||
|
|
||||||
|
Schema 11 adds `profile_revision` without changing stable identity keys. Run an
|
||||||
|
explicit migration before starting an adopter with automatic migration disabled.
|
||||||
|
Keep the pre-migration backup; adjacent older binaries are not approved writers
|
||||||
|
for the migrated schema. Email-change enrollment/confirmation is not implemented
|
||||||
|
by this interface and must not be simulated with an unverified direct update.
|
||||||
|
|||||||
+42
-2
@@ -8,6 +8,46 @@ application concern belongs in the shared module.
|
|||||||
|
|
||||||
## Gamertan accounts and commerce
|
## Gamertan accounts and commerce
|
||||||
|
|
||||||
|
- Personal identity editing is not instance administration. `OwnProfileRepository`
|
||||||
|
derives self-access from the active session; `ProfileEdit` is a trusted internal
|
||||||
|
command, never a browser request model. SQLite schema 11 adds a monotonic
|
||||||
|
revision because timestamps alone cannot distinguish two edits in one second.
|
||||||
|
Session/account/revision checks, mutation and audit share one write transaction.
|
||||||
|
Username edits invalidate other sessions without changing immutable IDs,
|
||||||
|
memberships, credentials, orders or provider billing identities. A password
|
||||||
|
proof binds the verified hash into that transaction; passkey proofs must bind
|
||||||
|
the exact user/session/field/value/revision before calling it. The application
|
||||||
|
chooses account-specific reauthentication and owns its credential-work limits.
|
||||||
|
Email requires a separate verified change protocol, not another accepted field.
|
||||||
|
|
||||||
|
- Instance operators need all-user/all-organization directories, not a staff
|
||||||
|
roster or implicit membership in every business. Optional bounded readers now
|
||||||
|
expose identity/profile records without credentials, independent of membership.
|
||||||
|
The application must authorize each call through an explicit instance scope;
|
||||||
|
these readers intentionally contain no Gamertan-specific roles or UI policy.
|
||||||
|
Stable-ID cursors and literal searches are covered against pagination gaps,
|
||||||
|
renamed profiles, inactive/personal records and wildcard/query injection.
|
||||||
|
- Customer profile and membership editing requires current ownership for every
|
||||||
|
write, not just changes involving another owner. The existing generic methods
|
||||||
|
intentionally permit application-authorized delegated administrators, so an
|
||||||
|
application preflight alone would leave a demotion race. Explicit owner-managed
|
||||||
|
methods now share their transactional cores while rechecking current direct
|
||||||
|
ownership before any write. Tests cover stale authority and optimistic state,
|
||||||
|
last-owner protection, concurrent winners, and audit-failure rollback. No extra
|
||||||
|
passkey ceremony or database migration is needed for this invariant.
|
||||||
|
- A customer may need both purchasing and billing access. Replacing one role at
|
||||||
|
a time would create partial permission states and misleading audit history.
|
||||||
|
The role-set extension commits all direct roles together with optimistic
|
||||||
|
binding IDs and current owner authority. Multiple-role invitations carry the
|
||||||
|
same combination atomically, with stored owner-managed policy rechecked when
|
||||||
|
accepted. SQLite tests cover concurrent winners, write rollback, demoted or
|
||||||
|
removed grantors, and attempted implicit reactivation of suspended members.
|
||||||
|
This adds schema 10; application vocabulary, allowed roles, invitation delivery,
|
||||||
|
ordinary-customer authentication, and UI/API commands remain application-owned.
|
||||||
|
- The public export allowlist omitted the owned-organization files introduced
|
||||||
|
in preview 22. Including them and building the exported tree tests the actual
|
||||||
|
distribution boundary rather than only comparing its path list with itself.
|
||||||
|
|
||||||
- Shared business purchasing exposed the difference between an initial member
|
- Shared business purchasing exposed the difference between an initial member
|
||||||
and an initial RBAC owner. The historical organization creation method commits
|
and an initial RBAC owner. The historical organization creation method commits
|
||||||
membership but no access binding. The new `CreateOwnedOrganization` extension
|
membership but no access binding. The new `CreateOwnedOrganization` extension
|
||||||
@@ -19,8 +59,8 @@ application concern belongs in the shared module.
|
|||||||
policy; there is no new database schema or commerce dependency in Foundations.
|
policy; there is no new database schema or commerce dependency in Foundations.
|
||||||
|
|
||||||
- The account email remains required and unique. Gamertan uses normalized
|
- The account email remains required and unique. Gamertan uses normalized
|
||||||
email as the canonical login identifier and keeps username as a stable public
|
email as the canonical login identifier; the immutable user ID, not the editable
|
||||||
identity. Until a mail package exists, the application must not describe an
|
username, owns account relationships. Until a mail package exists, it must not describe an
|
||||||
address as verified merely because it was entered during registration.
|
address as verified merely because it was entered during registration.
|
||||||
- Password authentication is sufficient for an ordinary customer base
|
- Password authentication is sufficient for an ordinary customer base
|
||||||
session. Privileged application actions use an exact operation binding with
|
session. Privileged application actions use an exact operation binding with
|
||||||
|
|||||||
@@ -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.22
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.26
|
||||||
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.22
|
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
|
||||||
|
|||||||
+88
-7
@@ -8,19 +8,57 @@ environments; environments own application services. Teams are optional groups
|
|||||||
of active organization members.
|
of active organization members.
|
||||||
|
|
||||||
`organizations.Service` creates those resources and issues digest-backed,
|
`organizations.Service` creates those resources and issues digest-backed,
|
||||||
expiring, single-use invitations. An invitation may carry one direct role and
|
expiring, single-use invitations. An invitation may carry up to sixteen direct
|
||||||
up to sixteen reviewed team memberships. Acceptance verifies that the
|
roles and sixteen reviewed team memberships. Acceptance verifies that the
|
||||||
authenticated user's normalized email matches and applies the membership,
|
authenticated user's normalized email matches and applies the membership,
|
||||||
role, teams, consumption marker, and audit event in one transaction.
|
roles, teams, consumption marker, and audit event in one transaction. The
|
||||||
When `OwnerRole` is configured, creating or revoking an invitation carrying
|
recipient and issuing member must remain active, fully registered users of an
|
||||||
that role additionally requires a current active direct owner inside the same
|
active organization; a suspended recipient cannot use an invitation as implicit
|
||||||
SQLite transaction. A broad access-management permission may administer
|
reactivation. Existing members use the membership editor, not another invitation,
|
||||||
ordinary invitations but cannot create or cancel owner access.
|
to change roles or teams. Duplicate or concurrent acceptance consumes the token
|
||||||
|
only once. Removal revokes older pending invitations for that recipient in the
|
||||||
|
same transaction; a new, intentional invitation is needed to rejoin later.
|
||||||
|
When `OwnerRole` is configured, invitations granting that role require a current
|
||||||
|
direct owner at creation and acceptance, and an owner for revocation. Set
|
||||||
|
`OwnerManagedInvitations: true` to apply that rule to every invitation, including
|
||||||
|
ordinary member invitations. Stored `RequiredOwnerRole` preserves the boundary
|
||||||
|
even when a link reaches another application service with different options.
|
||||||
|
A broad access-management permission can still administer ordinary invitations
|
||||||
|
when owner-managed policy is disabled, but cannot create or cancel owner access.
|
||||||
Applications own invitation pages, email or out-of-band delivery, active-source
|
Applications own invitation pages, email or out-of-band delivery, active-source
|
||||||
checks before archival, and account recovery.
|
checks before archival, and account recovery.
|
||||||
|
|
||||||
|
Use `InviteWithAccess.DirectRoles` for combinations and `RequestID` for the
|
||||||
|
creation audit correlation. `DirectRole` remains the legacy single-role form;
|
||||||
|
supplying both is rejected, not merged. The service copies and sorts role arrays
|
||||||
|
and rejects duplicates or unknown/unseeded roles before persistence. Repository
|
||||||
|
adapters implement `RoleInvitationRepository` to store and enforce role-set and
|
||||||
|
owner requirements atomically. An unsupported adapter returns
|
||||||
|
`ErrRoleInvitationUnsupported`; it must not issue a partly effective invitation.
|
||||||
|
The application restricts which roles may be offered and authenticates the actor;
|
||||||
|
never accept the owner-role policy or actor identity from submitted fields.
|
||||||
|
|
||||||
## Creating an organization with an owner
|
## 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,
|
||||||
@@ -36,6 +74,7 @@ a customer organization grants no authority in any other organization.
|
|||||||
```go
|
```go
|
||||||
customers, err := organizations.New(store, organizations.Options{
|
customers, err := organizations.New(store, organizations.Options{
|
||||||
OwnerRole: "customer.owner", // Application-defined, already seeded.
|
OwnerRole: "customer.owner", // Application-defined, already seeded.
|
||||||
|
OwnerManagedInvitations: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -55,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;
|
||||||
@@ -83,6 +133,37 @@ will not demote the final active direct owner. The application must still
|
|||||||
authorize the administrator and bind any required fresh passkey assertion to
|
authorize the administrator and bind any required fresh passkey assertion to
|
||||||
the organization, target user, target role, and expected IDs.
|
the organization, target user, target role, and expected IDs.
|
||||||
|
|
||||||
|
For combinations such as Buyer plus Billing Manager, use
|
||||||
|
`access.ReplaceOrganizationUserRoles` with a non-empty, unique `Roles` array
|
||||||
|
(maximum sixteen) and the same `ExpectedBindingIDs` convention. This operation
|
||||||
|
requires a current direct owner inside the write transaction for every change;
|
||||||
|
the older single-role API retains its delegated non-owner administration policy.
|
||||||
|
The replacement is all-or-nothing, leaves narrower grants untouched, and records
|
||||||
|
one audit. `ErrRoleChangeConflict` means refresh the displayed bindings, not retry
|
||||||
|
the old request silently. `RoleSetRepository` is required; separate grant/revoke
|
||||||
|
calls are not a fallback. A basic-member role with no permissions can represent
|
||||||
|
membership without purchasing or billing access.
|
||||||
|
|
||||||
|
Role names and capabilities remain application policy. In particular, customer
|
||||||
|
roles must not be replaceable with merchant roles merely because both policies
|
||||||
|
use the same database. Routine customer changes do not inherently require a
|
||||||
|
passkey ceremony; the application decides when an action needs fresh proof.
|
||||||
|
|
||||||
|
## Schema 10 compatibility
|
||||||
|
|
||||||
|
Schema 10 adds `direct_roles_json` and `required_owner_role` to stored invitations.
|
||||||
|
The explicit migration preserves legacy `direct_role`, hashes, dates, teams, and
|
||||||
|
consumption state. It does not guess which application role historically meant
|
||||||
|
owner. Configure the correct `OwnerRole` when accepting pre-schema-10 owner
|
||||||
|
invitations; that service policy supplies their acceptance-time owner check.
|
||||||
|
New invitations carry the persisted requirement themselves.
|
||||||
|
|
||||||
|
Use `OpenWithOptions(..., OpenOptions{Migrate: false})` plus
|
||||||
|
`RequireCurrentSchema` at application startup and an explicit operator migration
|
||||||
|
command. Retain a verified backup before migrating. Schema-9 binaries reject
|
||||||
|
schema 10 when using the startup check and are not approved writers after the
|
||||||
|
upgrade; a binary rollback must not overwrite newer accepted data.
|
||||||
|
|
||||||
`access.Service` evaluates a permission against a complete resource scope:
|
`access.Service` evaluates a permission against a complete resource scope:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|||||||
@@ -9,3 +9,8 @@ the exact same exported tree as a read-only discovery mirror.
|
|||||||
The exporter includes no branches, reflogs, private operational evidence,
|
The exporter includes no branches, reflogs, private operational evidence,
|
||||||
credentials, databases, logs, or development-only files. Public Gitea issues
|
credentials, databases, logs, or development-only files. Public Gitea issues
|
||||||
and pull requests are the contribution venue.
|
and pull requests are the contribution venue.
|
||||||
|
|
||||||
|
`scripts/test-public-snapshot.sh` checks the exact allowlist and builds all
|
||||||
|
exported packages. New implementation and regression-test files must be included
|
||||||
|
explicitly; a successful build in the development checkout does not prove that
|
||||||
|
the smaller exported distribution is complete.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -81,7 +82,12 @@ type Invitation struct {
|
|||||||
Digest [32]byte
|
Digest [32]byte
|
||||||
OrganizationID string
|
OrganizationID string
|
||||||
Email, InvitedByUserID string
|
Email, InvitedByUserID string
|
||||||
|
// DirectRole is the legacy single-role form. Use exactly one form.
|
||||||
DirectRole string
|
DirectRole string
|
||||||
|
DirectRoles []string
|
||||||
|
// RequiredOwnerRole records the grantor authority to recheck at acceptance.
|
||||||
|
// Services set it from their trusted configuration, never a request payload.
|
||||||
|
RequiredOwnerRole string
|
||||||
TeamIDs []string
|
TeamIDs []string
|
||||||
CreatedAt, ExpiresAt, UsedAt, RevokedAt time.Time
|
CreatedAt, ExpiresAt, UsedAt, RevokedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -129,6 +135,9 @@ type Options struct {
|
|||||||
Random io.Reader
|
Random io.Reader
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
OwnerRole string
|
OwnerRole string
|
||||||
|
// OwnerManagedInvitations requires a current direct owner to create, revoke,
|
||||||
|
// and remain the grantor of an invitation until it is accepted.
|
||||||
|
OwnerManagedInvitations bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -136,6 +145,7 @@ type Service struct {
|
|||||||
random io.Reader
|
random io.Reader
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
ownerRole string
|
ownerRole string
|
||||||
|
ownerManagedInvitations bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(repository Repository, options Options) (*Service, error) {
|
func New(repository Repository, options Options) (*Service, error) {
|
||||||
@@ -148,10 +158,10 @@ func New(repository Repository, options Options) (*Service, error) {
|
|||||||
if options.Now == nil {
|
if options.Now == nil {
|
||||||
options.Now = time.Now
|
options.Now = time.Now
|
||||||
}
|
}
|
||||||
if options.OwnerRole != "" && !safeNamePattern.MatchString(options.OwnerRole) {
|
if options.OwnerRole != "" && !safeNamePattern.MatchString(options.OwnerRole) || options.OwnerManagedInvitations && options.OwnerRole == "" {
|
||||||
return nil, errors.New("organizations: owner role is invalid")
|
return nil, errors.New("organizations: owner role is invalid")
|
||||||
}
|
}
|
||||||
return &Service{repository: repository, random: options.Random, now: options.Now, ownerRole: options.OwnerRole}, nil
|
return &Service{repository: repository, random: options.Random, now: options.Now, ownerRole: options.OwnerRole, ownerManagedInvitations: options.OwnerManagedInvitations}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateOrganization struct {
|
type CreateOrganization struct {
|
||||||
@@ -299,6 +309,8 @@ func (service *Service) Invite(ctx context.Context, organizationID, email, invit
|
|||||||
|
|
||||||
type InviteWithAccess struct {
|
type InviteWithAccess struct {
|
||||||
OrganizationID, Email, InvitedByUserID, DirectRole string
|
OrganizationID, Email, InvitedByUserID, DirectRole string
|
||||||
|
DirectRoles []string
|
||||||
|
RequestID string
|
||||||
TeamIDs []string
|
TeamIDs []string
|
||||||
Lifetime time.Duration
|
Lifetime time.Duration
|
||||||
}
|
}
|
||||||
@@ -307,9 +319,17 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
|
|||||||
organizationID, email, invitedBy, lifetime := input.OrganizationID, input.Email, input.InvitedByUserID, input.Lifetime
|
organizationID, email, invitedBy, lifetime := input.OrganizationID, input.Email, input.InvitedByUserID, input.Lifetime
|
||||||
email = strings.ToLower(strings.TrimSpace(email))
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
input.DirectRole = strings.TrimSpace(input.DirectRole)
|
input.DirectRole = strings.TrimSpace(input.DirectRole)
|
||||||
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour || input.DirectRole != "" && !safeNamePattern.MatchString(input.DirectRole) || !validIDs(input.TeamIDs, 16) {
|
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour || input.DirectRole != "" && !safeNamePattern.MatchString(input.DirectRole) || !validIDs(input.TeamIDs, 16) || !boundedOptional(input.RequestID, 128) {
|
||||||
return "", Invitation{}, errors.New("organizations: invalid invitation")
|
return "", Invitation{}, errors.New("organizations: invalid invitation")
|
||||||
}
|
}
|
||||||
|
roles, err := (Invitation{DirectRole: input.DirectRole, DirectRoles: input.DirectRoles}).RoleNames()
|
||||||
|
if err != nil {
|
||||||
|
return "", Invitation{}, err
|
||||||
|
}
|
||||||
|
roleRepository, roleSupport := service.repository.(RoleInvitationRepository)
|
||||||
|
if (len(input.DirectRoles) > 0 || service.ownerManagedInvitations || service.ownerRole != "" && slices.Contains(roles, service.ownerRole)) && !roleSupport {
|
||||||
|
return "", Invitation{}, ErrRoleInvitationUnsupported
|
||||||
|
}
|
||||||
id, err := token(service.random, 18)
|
id, err := token(service.random, 18)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", Invitation{}, err
|
return "", Invitation{}, err
|
||||||
@@ -320,11 +340,22 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
|
|||||||
}
|
}
|
||||||
now := service.now().UTC()
|
now := service.now().UTC()
|
||||||
invitation := Invitation{ID: id, Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, DirectRole: input.DirectRole, TeamIDs: append([]string(nil), input.TeamIDs...), CreatedAt: now, ExpiresAt: now.Add(lifetime)}
|
invitation := Invitation{ID: id, Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, DirectRole: input.DirectRole, TeamIDs: append([]string(nil), input.TeamIDs...), CreatedAt: now, ExpiresAt: now.Add(lifetime)}
|
||||||
audit, err := service.audit(invitedBy, organizationID, "invitation.create", "invitation", id, "Organization invitation created")
|
if len(input.DirectRoles) > 0 {
|
||||||
|
invitation.DirectRoles = roles
|
||||||
|
}
|
||||||
|
if service.ownerManagedInvitations || service.ownerRole != "" && slices.Contains(roles, service.ownerRole) {
|
||||||
|
invitation.RequiredOwnerRole = service.ownerRole
|
||||||
|
}
|
||||||
|
audit, err := service.auditWithRequest(invitedBy, organizationID, "invitation.create", "invitation", id, input.RequestID, "Organization invitation created")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", Invitation{}, err
|
return "", Invitation{}, err
|
||||||
}
|
}
|
||||||
if err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit); err != nil {
|
if roleSupport {
|
||||||
|
err = roleRepository.CreateInvitationWithRoles(ctx, invitation, service.ownerRole, audit)
|
||||||
|
} else {
|
||||||
|
err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return "", Invitation{}, err
|
return "", Invitation{}, err
|
||||||
}
|
}
|
||||||
return raw, invitation, nil
|
return raw, invitation, nil
|
||||||
@@ -344,6 +375,12 @@ func (service *Service) AcceptInvitation(ctx context.Context, rawToken, userID s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if repository, ok := service.repository.(RoleInvitationRepository); ok {
|
||||||
|
return repository.AcceptInvitationWithRoles(ctx, digest, userID, service.ownerRole, now, audit)
|
||||||
|
}
|
||||||
|
if len(invitation.DirectRoles) > 0 || invitation.RequiredOwnerRole != "" || service.ownerManagedInvitations {
|
||||||
|
return ErrRoleInvitationUnsupported
|
||||||
|
}
|
||||||
return service.repository.AcceptInvitation(ctx, digest, userID, now, audit)
|
return service.repository.AcceptInvitation(ctx, digest, userID, now, audit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,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")
|
||||||
@@ -385,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
|
||||||
@@ -494,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 ||
|
||||||
@@ -503,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,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")
|
||||||
@@ -542,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package organizations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrRoleInvitationUnsupported = errors.New("organizations: atomic role-set invitations are unsupported")
|
||||||
|
|
||||||
|
// RoleInvitationRepository must preserve the entire role set and its required
|
||||||
|
// owner authority, then commit acceptance, membership, grants and audit together.
|
||||||
|
type RoleInvitationRepository interface {
|
||||||
|
CreateInvitationWithRoles(context.Context, Invitation, string, AuditEvent) error
|
||||||
|
AcceptInvitationWithRoles(context.Context, [32]byte, string, string, time.Time, AuditEvent) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleNames returns a validated copy of the invitation's direct roles. The older
|
||||||
|
// DirectRole remains supported; supplying both forms is an error, not a union.
|
||||||
|
func (invitation Invitation) RoleNames() ([]string, error) {
|
||||||
|
if invitation.DirectRole != "" && len(invitation.DirectRoles) > 0 || len(invitation.DirectRoles) > 16 {
|
||||||
|
return nil, errors.New("organizations: invalid invitation roles")
|
||||||
|
}
|
||||||
|
roles := append([]string(nil), invitation.DirectRoles...)
|
||||||
|
if invitation.DirectRole != "" {
|
||||||
|
roles = append(roles, invitation.DirectRole)
|
||||||
|
}
|
||||||
|
slices.Sort(roles)
|
||||||
|
for i, role := range roles {
|
||||||
|
if !safeNamePattern.MatchString(role) || i > 0 && role == roles[i-1] {
|
||||||
|
return nil, errors.New("organizations: invalid invitation role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roles, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package organizations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roleInvitationRepositoryStub struct {
|
||||||
|
repositoryStub
|
||||||
|
audit AuditEvent
|
||||||
|
ownerRole string
|
||||||
|
created int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *roleInvitationRepositoryStub) CreateInvitationWithRoles(_ context.Context, invitation Invitation, ownerRole string, audit AuditEvent) error {
|
||||||
|
r.created++
|
||||||
|
r.invitation, r.audit, r.ownerRole = invitation, audit, ownerRole
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *roleInvitationRepositoryStub) AcceptInvitationWithRoles(_ context.Context, _ [32]byte, userID, ownerRole string, _ time.Time, audit AuditEvent) error {
|
||||||
|
r.acceptedUser, r.ownerRole, r.audit = userID, ownerRole, audit
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvitationRoleNamesAreBoundedAndUnambiguous(t *testing.T) {
|
||||||
|
input := []string{"buyer", "billing"}
|
||||||
|
roles, err := (Invitation{DirectRoles: input}).RoleNames()
|
||||||
|
if err != nil || !slices.Equal(roles, []string{"billing", "buyer"}) || !slices.Equal(input, []string{"buyer", "billing"}) {
|
||||||
|
t.Fatalf("roles=%v input=%v err=%v", roles, input, err)
|
||||||
|
}
|
||||||
|
for _, invitation := range []Invitation{
|
||||||
|
{DirectRole: "owner", DirectRoles: []string{"buyer"}},
|
||||||
|
{DirectRoles: []string{"buyer", "buyer"}},
|
||||||
|
{DirectRoles: []string{"not a role"}},
|
||||||
|
{DirectRoles: make([]string, 17)},
|
||||||
|
} {
|
||||||
|
if _, err := invitation.RoleNames(); err == nil {
|
||||||
|
t.Fatalf("invalid roles accepted=%+v", invitation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if roles, err = (Invitation{DirectRole: "buyer"}).RoleNames(); err != nil || !slices.Equal(roles, []string{"buyer"}) {
|
||||||
|
t.Fatalf("legacy=%v err=%v", roles, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationServicePreservesOwnerRequirementAndRequest(t *testing.T) {
|
||||||
|
r := &roleInvitationRepositoryStub{}
|
||||||
|
service, err := New(r, Options{OwnerRole: "owner", OwnerManagedInvitations: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := InviteWithAccess{OrganizationID: "organization-123", InvitedByUserID: "owner-12345678", Email: " Member@example.test ", DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour, RequestID: "request-invite"}
|
||||||
|
raw, invitation, err := service.InviteWithAccess(t.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if invitation.Digest != sha256.Sum256([]byte(raw)) || invitation.RequiredOwnerRole != "owner" || r.ownerRole != "owner" || r.audit.RequestID != input.RequestID || !slices.Equal(invitation.DirectRoles, []string{"billing", "buyer"}) || invitation.Email != "member@example.test" {
|
||||||
|
t.Fatalf("invitation or audit mismatch: %+v %+v", invitation, r.audit)
|
||||||
|
}
|
||||||
|
if !slices.Equal(input.DirectRoles, []string{"buyer", "billing"}) {
|
||||||
|
t.Fatal("caller roles mutated")
|
||||||
|
}
|
||||||
|
if err = service.AcceptInvitation(t.Context(), raw, "member-12345678"); err != nil || r.ownerRole != "owner" || r.acceptedUser != "member-12345678" {
|
||||||
|
t.Fatalf("accept=%v owner=%q user=%q", err, r.ownerRole, r.acceptedUser)
|
||||||
|
}
|
||||||
|
input.RequestID = strings.Repeat("x", 129)
|
||||||
|
if _, _, err = service.InviteWithAccess(t.Context(), input); err == nil || r.created != 1 {
|
||||||
|
t.Fatal("oversized request accepted")
|
||||||
|
}
|
||||||
|
if _, err = New(r, Options{OwnerManagedInvitations: true}); err == nil {
|
||||||
|
t.Fatal("owner-managed service without owner accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationHasNoPartialLegacyFallback(t *testing.T) {
|
||||||
|
for _, input := range []InviteWithAccess{
|
||||||
|
{DirectRoles: []string{"buyer", "billing"}}, {DirectRole: "owner"}, {},
|
||||||
|
} {
|
||||||
|
r := &repositoryStub{}
|
||||||
|
service, err := New(r, Options{OwnerRole: "owner", OwnerManagedInvitations: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input.OrganizationID, input.InvitedByUserID, input.Email, input.Lifetime = "organization-123", "owner-12345678", "member@example.test", time.Hour
|
||||||
|
if _, _, err = service.InviteWithAccess(t.Context(), input); !errors.Is(err, ErrRoleInvitationUnsupported) || r.invitation.ID != "" {
|
||||||
|
t.Fatalf("legacy fallback=%v", err)
|
||||||
|
}
|
||||||
|
r.invitation = Invitation{OrganizationID: input.OrganizationID, ID: "invitation-1234", RequiredOwnerRole: "owner"}
|
||||||
|
if err = service.AcceptInvitation(t.Context(), strings.Repeat("a", 43), "member-12345678"); !errors.Is(err, ErrRoleInvitationUnsupported) || r.acceptedUser != "" {
|
||||||
|
t.Fatalf("legacy acceptance fallback=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
.gitea/workflows/verify.yml
|
.gitea/workflows/verify.yml
|
||||||
.gitignore
|
.gitignore
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
|
AI_DISCLOSURE.md
|
||||||
CONTRIBUTING.md
|
CONTRIBUTING.md
|
||||||
LICENSE
|
LICENSE
|
||||||
LICENSES.md
|
LICENSES.md
|
||||||
@@ -20,12 +21,17 @@ abuse/abuse_test.go
|
|||||||
account/account.go
|
account/account.go
|
||||||
access/access.go
|
access/access.go
|
||||||
access/access_test.go
|
access/access_test.go
|
||||||
|
access/role_sets.go
|
||||||
|
access/role_sets_test.go
|
||||||
analytics/analytics.go
|
analytics/analytics.go
|
||||||
analytics/analytics_test.go
|
analytics/analytics_test.go
|
||||||
analytics/fuzz_test.go
|
analytics/fuzz_test.go
|
||||||
analytics/geo.go
|
analytics/geo.go
|
||||||
auth/auth.go
|
auth/auth.go
|
||||||
auth/context.go
|
auth/context.go
|
||||||
|
auth/directory.go
|
||||||
|
auth/profile.go
|
||||||
|
auth/profile_test.go
|
||||||
auth/password.go
|
auth/password.go
|
||||||
auth/password_test.go
|
auth/password_test.go
|
||||||
authrecovery/recovery.go
|
authrecovery/recovery.go
|
||||||
@@ -38,6 +44,10 @@ authhttp/passkey.go
|
|||||||
authhttp/passkey_test.go
|
authhttp/passkey_test.go
|
||||||
authsqlite/store.go
|
authsqlite/store.go
|
||||||
authsqlite/store_test.go
|
authsqlite/store_test.go
|
||||||
|
authsqlite/directory.go
|
||||||
|
authsqlite/directory_test.go
|
||||||
|
authsqlite/profile.go
|
||||||
|
authsqlite/profile_test.go
|
||||||
authsqlite/account.go
|
authsqlite/account.go
|
||||||
authsqlite/account_test.go
|
authsqlite/account_test.go
|
||||||
authsqlite/access.go
|
authsqlite/access.go
|
||||||
@@ -45,6 +55,10 @@ authsqlite/assisted_recovery.go
|
|||||||
authsqlite/bootstrap.go
|
authsqlite/bootstrap.go
|
||||||
authsqlite/bootstrap_test.go
|
authsqlite/bootstrap_test.go
|
||||||
authsqlite/organizations.go
|
authsqlite/organizations.go
|
||||||
|
authsqlite/owned_organization.go
|
||||||
|
authsqlite/owned_organization_test.go
|
||||||
|
authsqlite/owned_management_test.go
|
||||||
|
authsqlite/role_sets_test.go
|
||||||
authsqlite/passkey.go
|
authsqlite/passkey.go
|
||||||
authsqlite/passkey_test.go
|
authsqlite/passkey_test.go
|
||||||
authsqlite/recovery.go
|
authsqlite/recovery.go
|
||||||
@@ -83,6 +97,11 @@ requestmeta/requestmeta.go
|
|||||||
requestmeta/requestmeta_test.go
|
requestmeta/requestmeta_test.go
|
||||||
organizations/organizations.go
|
organizations/organizations.go
|
||||||
organizations/organizations_test.go
|
organizations/organizations_test.go
|
||||||
|
organizations/owned.go
|
||||||
|
organizations/directory.go
|
||||||
|
organizations/owned_test.go
|
||||||
|
organizations/role_invitations.go
|
||||||
|
organizations/role_invitations_test.go
|
||||||
scripts/check-licenses.sh
|
scripts/check-licenses.sh
|
||||||
scripts/check-dependencies.sh
|
scripts/check-dependencies.sh
|
||||||
scripts/check-vendored-webauthn.sh
|
scripts/check-vendored-webauthn.sh
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ while IFS= read -r path; do
|
|||||||
fi
|
fi
|
||||||
done < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) | LC_ALL=C sort >"$temporary/expected"
|
done < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) | LC_ALL=C sort >"$temporary/expected"
|
||||||
diff -u "$temporary/expected" "$temporary/actual"
|
diff -u "$temporary/expected" "$temporary/actual"
|
||||||
|
# The allowlist is a source distribution boundary: it must still contain the
|
||||||
|
# implementation files required by the exported packages, not just match itself.
|
||||||
|
(cd "$temporary/export" && GOWORK=off go build ./...)
|
||||||
private_word='PRI''VATE'
|
private_word='PRI''VATE'
|
||||||
token_word='to''ken'
|
token_word='to''ken'
|
||||||
private_pattern="BEGIN (RSA|OPENSSH|EC) ${private_word} KEY|Authorization: ${token_word}|/home/"'cole'"|/mnt/c/"'Users'"|"'eqlwiki'"-deploy|"'crspeelman'"@gmail\\.com"
|
private_pattern="BEGIN (RSA|OPENSSH|EC) ${private_word} KEY|Authorization: ${token_word}|/home/"'cole'"|/mnt/c/"'Users'"|"'eqlwiki'"-deploy|"'crspeelman'"@gmail\\.com"
|
||||||
|
|||||||
Reference in New Issue
Block a user