Compare commits

..
Author SHA1 Message Date
gamertan fe6bd94c9a Add atomic organization role administration
verify / verify (push) Successful in 3m39s
2026-09-03 22:51:53 -04:00
gamertan 17bd9453e2 Allow explicit local WebAuthn ports
verify / verify (push) Successful in 3m35s
2026-09-03 22:30:23 -04:00
gamertan d8b09c8ae5 Reject malformed PDF media uploads
verify / verify (push) Successful in 3m38s
2026-09-03 13:47:56 -04:00
19 changed files with 698 additions and 21 deletions
+32
View File
@@ -2,6 +2,38 @@
# Changelog
## v0.1.0-preview.16 — 2026-09-03
- Add bounded organization-member and direct user-role listings for
application-owned access administration pages. Direct listings deliberately
exclude team and narrower resource grants rather than flattening distinct
authority into one apparent role.
- Add atomic direct-role replacement with exact expected-binding checks,
transactional access audit, active-member validation, and final active
direct-owner protection. SQLite serializes competing replacements so stale
administration fails with a stable conflict instead of partially applying.
- Record the Gamertan administration dogfood boundary: applications authorize
the route and fresh passkey assertion, while Foundations owns the reusable
storage transaction and invariants.
## v0.1.0-preview.15 — 2026-09-03
- Permit applications to opt into an exact non-default HTTPS WebAuthn origin
port for `localhost` and reserved `.test` relying-party IDs. The configured
origin remains exact, production origins remain portless by default, and
malformed, default, non-canonical, zero, or out-of-range ports fail closed.
- Record the Gamertan local-Caddy dogfood pressure that required this explicit
development boundary without weakening cross-origin ceremony rejection.
## v0.1.0-preview.14 — 2026-09-03
- Reject header-only, truncated, and structurally invalid PDF uploads in the
bounded media preparer. Accepted attachments now require a supported PDF
version, terminal EOF marker, numeric in-range `startxref`, and either a
traditional xref/trailer or xref-stream object at the declared offset.
- Keep PDF handling storage-neutral and non-rendering: applications still own
authorization, reference tracking, attachment disposition, and lifecycle.
## v0.1.0-preview.13 — 2026-09-03
- Complete the password-plus-recovery-code flow with a short-lived restricted
+3 -3
View File
@@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and
deployment. Adopt one boundary at a time; Go compiles and links only the
packages you import.
> **Public preview:** `v0.1.0-preview.13`. APIs may change before a stable
> **Public preview:** `v0.1.0-preview.16`. APIs may change before a stable
> release. Linux is the maintained release platform.
## Why Web Foundations?
@@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy.
Pin the preview in an application module:
```bash
go get gamertan.com/web@v0.1.0-preview.13
go get gamertan.com/web@v0.1.0-preview.16
go mod verify
```
An application may name the first package it intends to adopt:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.13
go get gamertan.com/web/requestmeta@v0.1.0-preview.16
```
The version belongs to the `gamertan.com/web` module. See the
+87 -5
View File
@@ -18,8 +18,11 @@ import (
)
var (
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
ErrLastOwner = errors.New("access: the last active direct owner must be preserved")
ErrRoleChangeConflict = errors.New("access: role binding changed")
ErrRoleUnchanged = errors.New("access: role is unchanged")
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
)
type SubjectKind string
@@ -116,6 +119,8 @@ type Repository interface {
Grant(context.Context, Binding) error
Revoke(context.Context, string, string, time.Time) error
EffectiveBindings(context.Context, string, string) ([]Binding, error)
OrganizationUserBindings(context.Context, string, int) ([]Binding, error)
ReplaceOrganizationUserRole(context.Context, []string, Binding, string, AuditEvent) error
CreateBreakGlass(context.Context, BreakGlass, AuditEvent) error
ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error)
AppendAccessAudit(context.Context, AuditEvent) error
@@ -123,8 +128,9 @@ type Repository interface {
}
type Options struct {
Random io.Reader
Now func() time.Time
Random io.Reader
Now func() time.Time
OwnerRole string
}
type Service struct {
@@ -132,6 +138,7 @@ type Service struct {
policy Policy
random io.Reader
now func() time.Time
ownerRole string
}
func New(repository Repository, policy Policy, options Options) (*Service, error) {
@@ -147,7 +154,12 @@ func New(repository Repository, policy Policy, options Options) (*Service, error
if options.Now == nil {
options.Now = time.Now
}
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now}, nil
if options.OwnerRole != "" {
if _, ok := policy.Roles[options.OwnerRole]; !ok {
return nil, errors.New("access: owner role is unknown")
}
}
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now, ownerRole: options.OwnerRole}, nil
}
func (service *Service) Seed(ctx context.Context) error {
@@ -183,6 +195,62 @@ func (service *Service) Grant(ctx context.Context, input Grant) (Binding, error)
return binding, nil
}
// OrganizationUserBindings lists active, direct, organization-wide user role
// bindings. Team and narrower project/environment/service grants remain
// separate because an administration screen must not silently flatten their
// authority into one apparent role.
func (service *Service) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]Binding, error) {
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("access: invalid organization binding query")
}
return service.repository.OrganizationUserBindings(ctx, organizationID, limit)
}
type OrganizationUserRoleChange struct {
OrganizationID string
UserID string
Role string
ActorUserID string
RequestID string
ExpectedBindingIDs []string
}
// ReplaceOrganizationUserRole atomically replaces every current direct,
// organization-wide role for one active member with exactly one role. The
// expected binding IDs make concurrent administration fail closed. When an
// owner role is configured, the repository also protects the final active
// direct owner in the same transaction.
func (service *Service) ReplaceOrganizationUserRole(ctx context.Context, input OrganizationUserRoleChange) (Binding, error) {
if service.ownerRole == "" {
return Binding{}, errors.New("access: owner role is required for role replacement")
}
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || !text(input.RequestID, 128, true) {
return Binding{}, errors.New("access: invalid organization role replacement")
}
if _, ok := service.policy.Roles[input.Role]; !ok {
return Binding{}, errors.New("access: unknown role")
}
expected, err := canonicalBindingIDs(input.ExpectedBindingIDs)
if err != nil {
return Binding{}, err
}
bindingID, err := randomID(service.random)
if err != nil {
return Binding{}, err
}
auditID, err := randomID(service.random)
if err != nil {
return Binding{}, err
}
now := service.now().UTC()
binding := Binding{ID: bindingID, SubjectKind: User, SubjectID: input.UserID, Role: input.Role, Scope: Scope{OrganizationID: input.OrganizationID}, GrantedBy: input.ActorUserID, GrantedAt: now}
audit := AuditEvent{ID: auditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.role.replace", ResourceType: "user", ResourceID: input.UserID, RequestID: input.RequestID, Summary: "Direct organization role replaced", CreatedAt: now}
if err = service.repository.ReplaceOrganizationUserRole(ctx, expected, binding, service.ownerRole, audit); err != nil {
return Binding{}, err
}
return binding, nil
}
type Decision struct {
Allowed bool
Source string
@@ -265,6 +333,20 @@ func randomID(random io.Reader) (string, error) {
return base64.RawURLEncoding.EncodeToString(value), nil
}
func canonicalBindingIDs(values []string) ([]string, error) {
if len(values) > 16 {
return nil, errors.New("access: invalid expected role bindings")
}
result := append([]string(nil), values...)
sort.Strings(result)
for index, value := range result {
if !idPattern.MatchString(value) || index > 0 && result[index-1] == value {
return nil, errors.New("access: invalid expected role bindings")
}
}
return result, nil
}
func text(value string, limit int, emptyOK bool) bool {
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
}
+62 -2
View File
@@ -4,6 +4,8 @@ package access
import (
"context"
"errors"
"slices"
"strings"
"testing"
"time"
@@ -56,9 +58,57 @@ func TestScopeHierarchyAndLifetimeFailClosed(t *testing.T) {
}
}
func TestOrganizationUserRoleReplacementIsBoundedAndCanonical(t *testing.T) {
now := time.Unix(2000, 0).UTC()
policy := Policy{Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"}, Permissions: map[string]string{"site.view": "View site"}, Grants: map[string][]string{"owner": {"site.view"}, "viewer": {"site.view"}}}
if _, err := New(&repositoryStub{}, policy, Options{OwnerRole: "missing"}); err == nil {
t.Fatal("unknown owner role accepted")
}
repository := &repositoryStub{}
service, err := New(repository, policy, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
binding, err := service.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{
OrganizationID: "org-12345678",
UserID: "user-12345678",
Role: "viewer",
ActorUserID: "user-87654321",
RequestID: "request-12345678",
ExpectedBindingIDs: []string{"binding-22222222", "binding-11111111"},
})
if err != nil {
t.Fatal(err)
}
if binding.Role != "viewer" || binding.SubjectKind != User || binding.Scope != (Scope{OrganizationID: "org-12345678"}) || binding.GrantedAt != now {
t.Fatalf("binding=%+v", binding)
}
if !slices.Equal(repository.replacedExpected, []string{"binding-11111111", "binding-22222222"}) || repository.replacedOwnerRole != "owner" {
t.Fatalf("expected=%v owner=%q", repository.replacedExpected, repository.replacedOwnerRole)
}
if repository.replacedAccessAudit.Action != "access.role.replace" || repository.replacedAccessAudit.ResourceID != "user-12345678" || repository.replacedAccessAudit.RequestID != "request-12345678" {
t.Fatalf("audit=%+v", repository.replacedAccessAudit)
}
if _, err = service.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{OrganizationID: "org-12345678", UserID: "user-12345678", Role: "viewer", ActorUserID: "user-87654321", ExpectedBindingIDs: []string{"binding-11111111", "binding-11111111"}}); err == nil {
t.Fatal("duplicate expected binding accepted")
}
serviceWithoutOwner, err := New(&repositoryStub{}, policy, Options{})
if err != nil {
t.Fatal(err)
}
if _, err = serviceWithoutOwner.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{}); err == nil || errors.Is(err, ErrRoleChangeConflict) {
t.Fatalf("missing owner role err=%v", err)
}
}
type repositoryStub struct {
bindings []Binding
breakGlass []BreakGlass
bindings []Binding
breakGlass []BreakGlass
organizationUser []Binding
replacedExpected []string
replacedBinding Binding
replacedOwnerRole string
replacedAccessAudit AuditEvent
}
func (*repositoryStub) SeedAccessPolicy(context.Context, Policy) error { return nil }
@@ -67,6 +117,16 @@ func (*repositoryStub) Revoke(context.Context, string, string, time.Time) error
func (repository *repositoryStub) EffectiveBindings(context.Context, string, string) ([]Binding, error) {
return repository.bindings, nil
}
func (repository *repositoryStub) OrganizationUserBindings(context.Context, string, int) ([]Binding, error) {
return repository.organizationUser, nil
}
func (repository *repositoryStub) ReplaceOrganizationUserRole(_ context.Context, expected []string, binding Binding, ownerRole string, audit AuditEvent) error {
repository.replacedExpected = append([]string(nil), expected...)
repository.replacedBinding = binding
repository.replacedOwnerRole = ownerRole
repository.replacedAccessAudit = audit
return nil
}
func (repository *repositoryStub) CreateBreakGlass(_ context.Context, grant BreakGlass, _ AuditEvent) error {
repository.breakGlass = []BreakGlass{grant}
return nil
+154
View File
@@ -6,6 +6,7 @@ import (
"context"
"database/sql"
"errors"
"slices"
"time"
"gamertan.com/web/access"
@@ -134,6 +135,159 @@ func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userI
return result, rows.Err()
}
func (store *Store) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]access.Binding, error) {
if !opaqueID(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("authsqlite: invalid organization binding query")
}
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_id,b.role_name,b.granted_by_user_id,b.granted_at
FROM gwf_access_bindings b
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id
WHERE b.organization_id=? AND b.subject_kind='user'
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
AND b.revoked_at IS NULL
ORDER BY b.subject_id,b.role_name,b.id
LIMIT ?`, organizationID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]access.Binding, 0)
for rows.Next() {
var binding access.Binding
var granted int64
if err = rows.Scan(&binding.ID, &binding.SubjectID, &binding.Role, &binding.GrantedBy, &granted); err != nil {
return nil, err
}
binding.SubjectKind = access.User
binding.Scope = access.Scope{OrganizationID: organizationID}
binding.GrantedAt = time.Unix(granted, 0).UTC()
result = append(result, binding)
}
return result, rows.Err()
}
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 errors.New("authsqlite: invalid organization role replacement")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Acquire the SQLite write lock before reading the optimistic binding set.
// This serializes competing role replacements so the loser observes the
// committed binding IDs and returns ErrRoleChangeConflict instead of an
// ambiguous busy-snapshot error.
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
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_users u WHERE u.id=? AND u.status='active')`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: role grantor is not active in organization")
}
var active int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*)
FROM gwf_organization_memberships m
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'
WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil {
return err
}
if active != 1 {
return errors.New("authsqlite: access subject is not active in organization")
}
rows, err := tx.QueryContext(ctx, `SELECT id,role_name FROM gwf_access_bindings
WHERE organization_id=? AND subject_kind='user' AND subject_id=?
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
AND revoked_at IS NULL ORDER BY id`, replacement.Scope.OrganizationID, replacement.SubjectID)
if err != nil {
return err
}
var currentIDs []string
var currentRoles []string
for rows.Next() {
var id, role string
if err = rows.Scan(&id, &role); err != nil {
rows.Close()
return err
}
currentIDs = append(currentIDs, id)
currentRoles = append(currentRoles, role)
}
if err = rows.Err(); err != nil {
rows.Close()
return err
}
if err = rows.Close(); err != nil {
return err
}
if !slices.Equal(currentIDs, expected) {
return access.ErrRoleChangeConflict
}
if len(currentRoles) == 1 && currentRoles[0] == replacement.Role {
return access.ErrRoleUnchanged
}
if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) {
var otherOwners int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
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_users u ON u.id=m.user_id AND u.status='active'
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.revoked_at IS NULL`, replacement.Scope.OrganizationID, replacement.SubjectID, ownerRole).Scan(&otherOwners); err != nil {
return err
}
if otherOwners == 0 {
return access.ErrLastOwner
}
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=?
WHERE organization_id=? AND subject_kind='user' AND subject_id=?
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
AND revoked_at IS NULL`, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Scope.OrganizationID, replacement.SubjectID); err != nil {
return err
}
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at)
SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, replacement.ID, replacement.Scope.OrganizationID, replacement.SubjectID, replacement.Role, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Role)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: replacement role has not been seeded")
}
if err = appendAccessAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func validOrganizationRoleReplacement(expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) bool {
if !safeName(ownerRole) || !opaqueID(replacement.ID) || replacement.SubjectKind != access.User || !opaqueID(replacement.SubjectID) || !safeName(replacement.Role) || replacement.Scope.Validate() != nil || replacement.Scope.ProjectID != "" || replacement.Scope.EnvironmentID != "" || replacement.Scope.ServiceID != "" || !opaqueID(replacement.GrantedBy) || replacement.GrantedAt.IsZero() {
return false
}
if !validAccessAudit(audit) || audit.OrganizationID != replacement.Scope.OrganizationID || audit.ActorUserID != replacement.GrantedBy || audit.Action != "access.role.replace" || audit.ResourceType != "user" || audit.ResourceID != replacement.SubjectID || !audit.CreatedAt.Equal(replacement.GrantedAt) {
return false
}
if len(expected) > 16 || !slices.IsSorted(expected) {
return false
}
for index, id := range expected {
if !opaqueID(id) || index > 0 && expected[index-1] == id {
return false
}
}
return true
}
func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlass, audit access.AuditEvent) error {
if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID {
return errors.New("authsqlite: invalid break-glass event")
+28
View File
@@ -261,6 +261,34 @@ func (store *Store) MembershipsForUser(ctx context.Context, userID string) ([]or
return result, rows.Err()
}
func (store *Store) OrganizationMemberships(ctx context.Context, organizationID string, limit int) ([]organizations.Membership, error) {
if !opaqueID(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("authsqlite: invalid organization member query")
}
rows, err := store.db.QueryContext(ctx, `SELECT m.user_id,m.status,m.joined_at
FROM gwf_organization_memberships m
JOIN gwf_organizations o ON o.id=m.organization_id
WHERE m.organization_id=?
ORDER BY m.joined_at,m.user_id
LIMIT ?`, organizationID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]organizations.Membership, 0)
for rows.Next() {
var membership organizations.Membership
var joined int64
if err = rows.Scan(&membership.UserID, &membership.Status, &joined); err != nil {
return nil, err
}
membership.OrganizationID = organizationID
membership.JoinedAt = time.Unix(joined, 0).UTC()
result = append(result, membership)
}
return result, rows.Err()
}
func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID string) ([]organizations.Team, error) {
if !opaqueID(organizationID) || !opaqueID(userID) {
return nil, errors.New("authsqlite: invalid team query")
+166
View File
@@ -477,3 +477,169 @@ func TestInvitationAccessLifecycleAndLastOwnerProtection(t *testing.T) {
t.Fatalf("archived organization decision=%+v err=%v", decision, err)
}
}
func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 3, 16, 0, 0, 0, time.UTC)
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "access.owner", Email: "access-owner@example.test", DisplayName: "Access Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "access.member", Email: "access-member@example.test", DisplayName: "Access Member", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "access-admin", Name: "Access Admin", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, _, err := organizationService.Invite(t.Context(), organization.ID, member.Email, owner.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"},
Permissions: map[string]string{"site.view": "View site"},
Grants: map[string][]string{"owner": {"site.view"}, "viewer": {"site.view"}},
}
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
ownerBinding, err := accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID})
if err != nil {
t.Fatal(err)
}
memberBinding, err := accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID})
if err != nil {
t.Fatal(err)
}
project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "narrow", Name: "Narrow"})
if err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID, ProjectID: project.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
members, err := organizationService.Members(t.Context(), organization.ID, 10)
if err != nil || len(members) != 2 || !membershipPresent(members, owner.ID, "active") || !membershipPresent(members, member.ID, "active") {
t.Fatalf("members=%+v err=%v", members, err)
}
direct, err := accessService.OrganizationUserBindings(t.Context(), organization.ID, 10)
if err != nil || len(direct) != 2 {
t.Fatalf("direct=%+v err=%v", direct, err)
}
type replacementResult struct {
binding access.Binding
err error
}
start := make(chan struct{})
results := make(chan replacementResult, 2)
for _, requestID := range []string{"request-member-owner-one", "request-member-owner-two"} {
requestID := requestID
go func() {
<-start
binding, replaceErr := accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: owner.ID, RequestID: requestID, ExpectedBindingIDs: []string{memberBinding.ID}})
results <- replacementResult{binding: binding, err: replaceErr}
}()
}
close(start)
var memberOwner access.Binding
var successful, conflicted int
for range 2 {
result := <-results
switch {
case result.err == nil:
successful++
memberOwner = result.binding
case errors.Is(result.err, access.ErrRoleChangeConflict):
conflicted++
default:
t.Fatalf("concurrent replacement err=%v", result.err)
}
}
if successful != 1 || conflicted != 1 {
t.Fatalf("concurrent replacements success=%d conflict=%d", successful, conflicted)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: owner.ID, RequestID: "request-stale", ExpectedBindingIDs: []string{memberBinding.ID}}); !errors.Is(err, access.ErrRoleChangeConflict) {
t.Fatalf("stale replacement err=%v", err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: owner.ID, RequestID: "request-unchanged", ExpectedBindingIDs: []string{memberOwner.ID}}); !errors.Is(err, access.ErrRoleUnchanged) {
t.Fatalf("unchanged replacement err=%v", err)
}
ownerViewer, err := accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-owner-viewer", ExpectedBindingIDs: []string{ownerBinding.ID}})
if err != nil {
t.Fatal(err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-last-owner", ExpectedBindingIDs: []string{memberOwner.ID}}); !errors.Is(err, access.ErrLastOwner) {
t.Fatalf("last-owner demotion err=%v", err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "owner", ActorUserID: member.ID, RequestID: "request-restore-owner", ExpectedBindingIDs: []string{ownerViewer.ID}}); err != nil {
t.Fatal(err)
}
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, member.ID, "suspended", owner.ID, "request-suspend"); err != nil {
t.Fatal(err)
}
members, err = organizationService.Members(t.Context(), organization.ID, 10)
if err != nil || len(members) != 2 || !membershipPresent(members, member.ID, "suspended") {
t.Fatalf("suspended members=%+v err=%v", members, err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: owner.ID, RequestID: "request-suspended", ExpectedBindingIDs: []string{memberOwner.ID}}); err == nil {
t.Fatal("suspended member role was replaced")
}
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, member.ID, "active", owner.ID, "request-reactivate"); err != nil {
t.Fatal(err)
}
duplicateAudit := access.AuditEvent{ID: "audit-duplicate-1234", OrganizationID: organization.ID, ActorUserID: owner.ID, Action: "access.role.replace", ResourceType: "user", ResourceID: member.ID, RequestID: "request-rollback", Summary: "Direct organization role replaced", CreatedAt: now}
if err = store.AppendAccessAudit(t.Context(), duplicateAudit); err != nil {
t.Fatal(err)
}
replacement := access.Binding{ID: "binding-rollback-1234", SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID, GrantedAt: now}
if err = store.ReplaceOrganizationUserRole(t.Context(), []string{memberOwner.ID}, replacement, "owner", duplicateAudit); err == nil {
t.Fatal("audit failure did not roll back role replacement")
}
direct, err = store.OrganizationUserBindings(t.Context(), organization.ID, 10)
if err != nil {
t.Fatal(err)
}
var memberRoles []string
for _, binding := range direct {
if binding.SubjectID == member.ID {
memberRoles = append(memberRoles, binding.ID+":"+binding.Role)
}
}
if len(memberRoles) != 1 || memberRoles[0] != memberOwner.ID+":owner" {
t.Fatalf("rollback member roles=%v", memberRoles)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0)
}
func membershipPresent(values []organizations.Membership, userID, status string) bool {
for _, value := range values {
if value.UserID == userID && value.Status == status {
return true
}
}
return false
}
+25 -6
View File
@@ -12,8 +12,10 @@ import (
"errors"
"fmt"
"io"
"net"
"net/url"
"regexp"
"strconv"
"strings"
"time"
@@ -36,9 +38,14 @@ const (
var accountNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
type Config struct {
RPID string
RPDisplayName string
Origin string
RPID string
RPDisplayName string
Origin string
// AllowDevelopmentPort permits an explicit non-default HTTPS port only
// for localhost or a reserved .test relying-party ID. Production origins
// remain portless, while local applications can terminate trusted HTTPS
// without requiring a privileged listener.
AllowDevelopmentPort bool
EnrollmentLifetime time.Duration
RegistrationTTL time.Duration
LoginTTL time.Duration
@@ -66,7 +73,7 @@ func New(repository Repository, authService *auth.Service, config Config) (*Serv
if repository == nil || authService == nil {
return nil, errors.New("authwebauthn: repository and auth service are required")
}
if err := validateOrigin(config.RPID, config.Origin); err != nil {
if err := validateOrigin(config.RPID, config.Origin, config.AllowDevelopmentPort); err != nil {
return nil, err
}
if strings.TrimSpace(config.RPDisplayName) == "" || len(config.RPDisplayName) > 80 {
@@ -725,12 +732,24 @@ func passwordMigrationBinding(userID string) [32]byte {
return BindingDigest([]byte("gamertan-web/password-to-passkey/v1\x00" + userID))
}
func validateOrigin(rpID, rawOrigin string) error {
func validateOrigin(rpID, rawOrigin string, allowDevelopmentPort bool) error {
if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" {
return errors.New("authwebauthn: relying-party ID and origin are required")
}
origin, err := url.Parse(rawOrigin)
if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.Port() != "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
port := origin.Port()
if port == "" {
if origin.Host != rpID {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
return nil
}
developmentRP := rpID == "localhost" || strings.HasSuffix(rpID, ".test")
value, portErr := strconv.ParseUint(port, 10, 16)
if !allowDevelopmentPort || !developmentRP || portErr != nil || value == 0 || value == 443 || strconv.FormatUint(value, 10) != port || origin.Host != net.JoinHostPort(rpID, port) {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
return nil
+14
View File
@@ -231,11 +231,25 @@ func TestConfigurationAndEntropyFailures(t *testing.T) {
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "http://tend.gamertan.com"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://other.gamertan.com"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com/path"},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:8443"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com:8443", AllowDevelopmentPort: true},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:443", AllowDevelopmentPort: true},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:08443", AllowDevelopmentPort: true},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:0", AllowDevelopmentPort: true},
} {
if _, err = authwebauthn.New(store, authService, config); err == nil {
t.Fatalf("accepted config=%+v", config)
}
}
for _, config := range []authwebauthn.Config{
{RPID: "localhost", RPDisplayName: "Tend Local", Origin: "https://localhost:8443", AllowDevelopmentPort: true},
{RPID: "tend.test", RPDisplayName: "Tend Local", Origin: "https://tend.test:8443", AllowDevelopmentPort: true},
} {
configured, configureErr := authwebauthn.New(store, authService, config)
if configureErr != nil || configured == nil {
t.Fatalf("development config=%+v service=%v err=%v", config, configured, configureErr)
}
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: failingReader{}})
if err != nil {
t.Fatal(err)
+11
View File
@@ -49,3 +49,14 @@ application concern belongs in the shared module.
passkey and replacement code digests. `authrecovery.BeginPasskey` and
`FinishPasskey` now provide that boundary without creating an authenticated
session; Gamertan keeps the raw grant only in a short-lived HttpOnly cookie.
- A portless-only WebAuthn origin rule made an unprivileged local HTTPS
exercise impossible even though WebAuthn origins include ports. The passkey
service now permits an explicit development port only when applications opt
in and the RP ID is `localhost` or reserved `.test`; production origins keep
the original portless default.
- Gamertan's staff-access page exposed a dangerous composition gap between
individual grant/revoke calls. Foundations now owns one optimistic,
transactional direct-role replacement that preserves the final active
owner and appends its audit before commit. The application still owns route
authorization, role presentation, CSRF, and the exact fresh-passkey
operation binding.
+1 -1
View File
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
module checksum:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.13
go get gamertan.com/web/requestmeta@v0.1.0-preview.16
go mod verify
```
+1 -1
View File
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
and request the containing module at an exact version:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.13
go get gamertan.com/web/requestmeta@v0.1.0-preview.16
```
Only imported packages are compiled and linked. The packages nevertheless
+12
View File
@@ -22,6 +22,18 @@ team membership can be removed independently. Configure `OwnerRole` when
constructing the service before exposing membership-removal operations. The
SQLite adapter then refuses to suspend or remove the final active direct owner.
For a reviewed access-administration page, use `organizations.Members` to list
bounded active and suspended memberships, and
`access.OrganizationUserBindings` to list only current direct,
organization-wide user roles. The latter intentionally excludes team grants
and project, environment, or service bindings. Replace a member's direct role
with `access.ReplaceOrganizationUserRole`, passing the exact displayed binding
IDs as `ExpectedBindingIDs`. The SQLite adapter serializes that replacement,
rejects stale state, writes the new binding and audit event atomically, and
will not demote the final active direct owner. The application must still
authorize the administrator and bind any required fresh passkey assertion to
the organization, target user, target role, and expected IDs.
`access.Service` evaluates a permission against a complete resource scope:
```go
+5
View File
@@ -9,6 +9,11 @@ authorization decisions, session cookie, HTML, and local recovery command.
## Fixed security policy
- Use an exact HTTPS origin whose hostname equals the relying-party ID.
- Keep production origins portless. For local development only,
`AllowDevelopmentPort` permits one explicit non-default port when the RP ID
is exactly `localhost` or beneath the reserved `.test` top-level domain. The
configured origin, browser `Origin`, and WebAuthn verifier origin must still
match exactly.
- Reject cross-origin ceremonies.
- Require discoverable credentials and user verification.
- Request no attestation conveyance.
+57 -1
View File
@@ -20,6 +20,7 @@ import (
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
@@ -121,7 +122,7 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
}
detected := http.DetectContentType(data)
if detected == "application/pdf" && bytes.HasPrefix(data, []byte("%PDF-")) {
if detected == "application/pdf" && validPDF(data) {
result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name}
result.Digest = sha256.Sum256(result.Data)
return result, nil
@@ -156,6 +157,61 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
return result, nil
}
// validPDF performs a deliberately bounded structural check without trying to
// render or interpret document content. It rejects header-only spoofing and
// truncated uploads by requiring a supported header, terminal EOF marker, a
// numeric startxref offset, and either a traditional xref table with trailer
// or an xref-stream object at that offset.
func validPDF(data []byte) bool {
if len(data) < 32 || !bytes.HasPrefix(data, []byte("%PDF-")) {
return false
}
headerEnd := bytes.IndexAny(data, "\r\n")
if headerEnd < 8 || headerEnd > 32 {
return false
}
header := string(bytes.TrimSpace(data[:headerEnd]))
if header != "%PDF-1.0" && header != "%PDF-1.1" && header != "%PDF-1.2" && header != "%PDF-1.3" && header != "%PDF-1.4" && header != "%PDF-1.5" && header != "%PDF-1.6" && header != "%PDF-1.7" && header != "%PDF-2.0" {
return false
}
trimmed := bytes.TrimRight(data, "\x00\t\n\f\r ")
if !bytes.HasSuffix(trimmed, []byte("%%EOF")) {
return false
}
eof := len(trimmed) - len("%%EOF")
start := bytes.LastIndex(trimmed[:eof], []byte("startxref"))
if start < headerEnd {
return false
}
cursor := start + len("startxref")
for cursor < eof && (trimmed[cursor] == ' ' || trimmed[cursor] == '\t' || trimmed[cursor] == '\r' || trimmed[cursor] == '\n' || trimmed[cursor] == '\f') {
cursor++
}
digits := cursor
for cursor < eof && trimmed[cursor] >= '0' && trimmed[cursor] <= '9' && cursor-digits < 20 {
cursor++
}
if cursor == digits {
return false
}
if len(bytes.TrimSpace(trimmed[cursor:eof])) != 0 {
return false
}
offset, err := strconv.ParseInt(string(trimmed[digits:cursor]), 10, 64)
if err != nil || offset < int64(headerEnd+1) || offset >= int64(start) {
return false
}
target := trimmed[int(offset):start]
if bytes.HasPrefix(target, []byte("xref")) {
return bytes.Contains(target, []byte("trailer"))
}
lineEnd := bytes.IndexByte(target, '\n')
if lineEnd < 5 || lineEnd > 80 || !bytes.Contains(target[:lineEnd], []byte(" obj")) {
return false
}
return bytes.Contains(target, []byte("/Type /XRef")) || bytes.Contains(target, []byte("/Type/XRef"))
}
func Extension(mediaType string) string {
switch mediaType {
case "image/jpeg":
+21 -1
View File
@@ -5,6 +5,7 @@ package media
import (
"bytes"
"errors"
"fmt"
"image"
"image/color"
"image/jpeg"
@@ -33,7 +34,8 @@ func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) {
}
func TestPreparePDFIsAttachment(t *testing.T) {
prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{})
pdf := minimalPDF()
prepared, err := Prepare(bytes.NewReader(pdf), "guide.pdf", Limits{})
if err != nil {
t.Fatal(err)
}
@@ -42,6 +44,18 @@ func TestPreparePDFIsAttachment(t *testing.T) {
}
}
func TestPrepareRejectsMalformedPDF(t *testing.T) {
for _, source := range []string{
"%PDF-1.7\nsmall fixture",
"%PDF-9.9\nxref\ntrailer\nstartxref\n9\n%%EOF",
"%PDF-1.7\nxref\ntrailer\nstartxref\n999999\n%%EOF",
} {
if _, err := Prepare(strings.NewReader(source), "broken.pdf", Limits{}); !errors.Is(err, ErrInvalidMedia) {
t.Fatalf("malformed PDF error=%v source=%q", err, source)
}
}
}
func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) {
t.Fatalf("svg err=%v", err)
@@ -50,3 +64,9 @@ func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
t.Fatalf("large err=%v", err)
}
}
func minimalPDF() []byte {
prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")
offset := len(prefix)
return append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", offset))...)
}
+4 -1
View File
@@ -5,6 +5,7 @@ package medialocal
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -20,7 +21,9 @@ func TestStoreRoundTripAndIdempotentPut(t *testing.T) {
if err != nil {
t.Fatal(err)
}
prepared, err := media.Prepare(bytes.NewReader([]byte("%PDF-1.7\nfixture")), "fixture.pdf", media.Limits{})
prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")
pdf := append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(prefix)))...)
prepared, err := media.Prepare(bytes.NewReader(pdf), "fixture.pdf", media.Limits{})
if err != nil {
t.Fatal(err)
}
+11
View File
@@ -107,6 +107,7 @@ type Repository interface {
Invitations(context.Context, string, int) ([]Invitation, error)
RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
OrganizationMemberships(context.Context, string, int) ([]Membership, error)
MembershipsForUser(context.Context, string) ([]Membership, error)
TeamsForUser(context.Context, string, string) ([]Team, error)
}
@@ -331,6 +332,16 @@ func (service *Service) Memberships(ctx context.Context, userID string) ([]Membe
return service.repository.MembershipsForUser(ctx, userID)
}
// Members returns a bounded, stable list of active and suspended memberships
// for one organization. Authorization remains an application concern because
// the same storage primitive serves different organization policies.
func (service *Service) Members(ctx context.Context, organizationID string, limit int) ([]Membership, error) {
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("organizations: invalid member query")
}
return service.repository.OrganizationMemberships(ctx, organizationID, limit)
}
func (service *Service) Teams(ctx context.Context, organizationID, userID string) ([]Team, error) {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) {
return nil, errors.New("organizations: invalid team query")
+4
View File
@@ -52,6 +52,7 @@ type repositoryStub struct {
invitation Invitation
invitationErr error
acceptedUser string
members []Membership
}
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership, _ AuditEvent) error {
@@ -109,4 +110,7 @@ func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte
func (*repositoryStub) MembershipsForUser(context.Context, string) ([]Membership, error) {
return nil, nil
}
func (repository *repositoryStub) OrganizationMemberships(context.Context, string, int) ([]Membership, error) {
return repository.members, nil
}
func (*repositoryStub) TeamsForUser(context.Context, string, string) ([]Team, error) { return nil, nil }