Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c68a3499a | ||
|
|
d476179148 | ||
|
|
f142ac23a9 | ||
|
|
c0986168bc | ||
|
|
ed0cc8ceff | ||
|
|
b1710e08b8 | ||
|
|
3fe1547a5b | ||
|
|
d54d6a4ad1 | ||
|
|
6f0b597943 | ||
|
|
59827bf641 | ||
|
|
fe6bd94c9a | ||
|
|
17bd9453e2 | ||
|
|
d8b09c8ae5 | ||
|
|
95d50f0888 | ||
|
|
1f54c75501 | ||
|
|
277cffed8c | ||
|
|
92ef63ba00 | ||
|
|
337b56ec1b | ||
|
|
7c8f0d708e | ||
|
|
a769d1ea7b |
@@ -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.
|
||||||
+236
-1
@@ -2,7 +2,242 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased
|
## 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
|
||||||
|
|
||||||
|
- Add `organizations.CreateOwnedOrganization` for atomic creation of an existing
|
||||||
|
user's organization, initial membership, direct configured owner role, and
|
||||||
|
correlated organization/access audits.
|
||||||
|
- Require an active, fully registered owner and a pre-seeded role inside the
|
||||||
|
SQLite transaction. Missing storage support fails without a non-atomic fallback.
|
||||||
|
- Preserve the older membership-only creation API and schema version 9. Customer
|
||||||
|
and merchant permissions remain application-owned, with no commerce dependency.
|
||||||
|
- Exercise failure at every write stage, concurrent duplicate creation, scoped
|
||||||
|
access, restart recovery, last-owner protection, and mismatched authority/audits.
|
||||||
|
|
||||||
|
## v0.1.0-preview.21 — 2026-09-04
|
||||||
|
|
||||||
|
- Derive the registered credential algorithm from the verified COSE public key
|
||||||
|
embedded in authenticator data instead of the optional browser
|
||||||
|
`publicKeyAlgorithm` convenience member.
|
||||||
|
- Preserve the ES256-only policy while accepting standards-compliant response
|
||||||
|
serializers that omit redundant response conveniences, including the
|
||||||
|
Bitwarden/Vaultwarden passkey flow exercised through Gamertan.
|
||||||
|
- Add regression coverage for an ES256 credential whose convenience algorithm
|
||||||
|
is absent, plus malformed and non-ES256 credential rejection.
|
||||||
|
|
||||||
|
## v0.1.0-preview.20 — 2026-09-04
|
||||||
|
|
||||||
|
- Extend the direct-owner transaction boundary to invitations. Creating or
|
||||||
|
revoking an invitation that grants the configured owner role now requires
|
||||||
|
the actor to remain an active direct owner after the SQLite write lock is
|
||||||
|
acquired.
|
||||||
|
- Preserve application-owned permission policy for ordinary invitations while
|
||||||
|
preventing a broad access-management role, stale ceremony, or alternate
|
||||||
|
repository call from creating or cancelling owner access.
|
||||||
|
- Pass the configured owner role explicitly through invitation repository
|
||||||
|
mutations so non-SQLite adapters cannot silently omit the invariant.
|
||||||
|
|
||||||
|
## v0.1.0-preview.19 — 2026-09-04
|
||||||
|
|
||||||
|
- Require a current active direct owner for every direct-role transition to or
|
||||||
|
from the configured owner role. The SQLite adapter rechecks that authority
|
||||||
|
after acquiring its write lock, preventing a role manager from promoting
|
||||||
|
itself or changing an owner through a stale application authorization.
|
||||||
|
- Apply the same transactional owner-authority boundary to membership
|
||||||
|
suspension, reactivation, and removal, including the legacy lifecycle
|
||||||
|
methods. Non-owner administrators may still manage non-owner members while
|
||||||
|
last-owner protection remains a separate invariant.
|
||||||
|
- Expose stable owner-authority errors so applications can distinguish an
|
||||||
|
authorization drift conflict from malformed input or storage failure.
|
||||||
|
|
||||||
|
## v0.1.0-preview.18 — 2026-09-04
|
||||||
|
|
||||||
|
- Add owner-assisted account recovery for a documented human-review path when
|
||||||
|
normal password, passkey, and recovery-code authentication is unavailable.
|
||||||
|
Issuance requires an active direct organization owner and returns a bounded,
|
||||||
|
single-use, 15-minute secret while persisting and auditing only its digest.
|
||||||
|
- Invalidate the recovered member's existing password, passkeys, recovery
|
||||||
|
codes, sessions, ceremonies, and older recovery grants when the reviewed
|
||||||
|
enrollment is issued. Completion atomically installs one replacement
|
||||||
|
password, passkey, and recovery-code set without issuing a normal session.
|
||||||
|
- Keep identity and organization-visible recovery audits in the same SQLite
|
||||||
|
transactions as their credential changes, and document the application
|
||||||
|
boundary for fresh passkey authorization, secret-fragment delivery, and
|
||||||
|
human evidence review.
|
||||||
|
|
||||||
|
## v0.1.0-preview.17 — 2026-09-04
|
||||||
|
|
||||||
|
- Add optimistic organization-membership suspension, reactivation, and
|
||||||
|
removal for fresh-authentication administration flows. The exact displayed
|
||||||
|
membership state is rechecked after acquiring the SQLite write lock, so a
|
||||||
|
concurrent or stale ceremony fails without changing access or writing an
|
||||||
|
audit event.
|
||||||
|
- Keep membership lifecycle consequences transactional: suspension removes
|
||||||
|
team membership, removal also revokes direct bindings, reactivation does not
|
||||||
|
silently restore former teams, and every successful change appends its
|
||||||
|
organization-visible audit before commit.
|
||||||
|
- Strengthen last-owner protection to require another active direct owner
|
||||||
|
whose platform account is also active. Existing storage adapters retain the
|
||||||
|
legacy interface; security-sensitive applications fail closed unless their
|
||||||
|
repository implements the optimistic lifecycle extension.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
grant bound into a replacement-passkey ceremony. Completion atomically
|
||||||
|
consumes the grant, stores the verified passkey, replaces every recovery
|
||||||
|
code, revokes any intervening sessions and ceremonies, and records both
|
||||||
|
audits without issuing a normal session.
|
||||||
|
- Keep failed completion retryable until grant expiry: a duplicate credential
|
||||||
|
or other transaction failure rolls back grant consumption and recovery-code
|
||||||
|
replacement, while a mismatched WebAuthn binding consumes only the affected
|
||||||
|
ceremony.
|
||||||
|
|
||||||
|
## v0.1.0-preview.12 — 2026-09-03
|
||||||
|
|
||||||
|
- Add a root-local bootstrap transaction that creates the first passkey-only
|
||||||
|
application owner, non-personal organization, active membership, direct
|
||||||
|
owner binding, one-time enrollment digest, and secret-free audit records
|
||||||
|
atomically.
|
||||||
|
- Fail closed and roll back the entire bootstrap when the application has not
|
||||||
|
seeded the configured owner role. The raw enrollment token is returned only
|
||||||
|
after commit and never enters repository state or audit records.
|
||||||
|
|
||||||
|
## v0.1.0-preview.11 — 2026-09-03
|
||||||
|
|
||||||
|
- Add expected-user completion for authenticated self-service passkey
|
||||||
|
enrollment. A mismatched ceremony is consumed and fails before credential
|
||||||
|
persistence, closing an authorization seam found while dogfooding Gamertan's
|
||||||
|
account security page.
|
||||||
|
|
||||||
|
## v0.1.0-preview.10 — 2026-09-03
|
||||||
|
|
||||||
|
- Add atomic public-account registration with required canonical email,
|
||||||
|
password authentication, printable recovery codes, a personal organization,
|
||||||
|
direct owner access, and an optional initial passkey. Pending registrations
|
||||||
|
cannot authenticate, and abandoned drafts expire without reserving identity
|
||||||
|
fields indefinitely.
|
||||||
|
- Add password verification without session issuance plus operation-bound
|
||||||
|
WebAuthn completion hooks, allowing applications to require fresh passkeys
|
||||||
|
for sensitive actions without imposing passkeys on ordinary customer use.
|
||||||
|
- Add digest-only recovery-code persistence and short-lived, single-use
|
||||||
|
recovery grants that consume a code and revoke existing sessions atomically.
|
||||||
|
- Add bounded raster/PDF media preparation and a hardened content-addressed
|
||||||
|
local filesystem adapter with atomic writes, private modes, and symlink
|
||||||
|
rejection.
|
||||||
|
- Add explicit SQLite open-without-migration and schema-requirement APIs while
|
||||||
|
preserving the historical migrating `Open` behavior for existing adopters.
|
||||||
|
- Record application dogfood findings and the independent future commerce
|
||||||
|
module boundary.
|
||||||
|
|
||||||
|
## v0.1.0-preview.9 — 2026-09-03
|
||||||
|
|
||||||
|
- Add a documented root package and executable composition example so the
|
||||||
|
module landing page presents its purpose, package-selection guidance,
|
||||||
|
security model, and `net/http` integration rather than only a directory
|
||||||
|
index.
|
||||||
|
- Add the repository's default MPL-2.0 licence at the conventional root path
|
||||||
|
so Go package tooling can identify the library licence while preserving the
|
||||||
|
existing file-level exceptions for starters and operational machinery.
|
||||||
|
- Rework the public README around progressive adoption, explicit design
|
||||||
|
promises, package selection, assurance gates, and canonical project links.
|
||||||
|
|
||||||
|
## v0.1.0-preview.8 — 2026-08-28
|
||||||
|
|
||||||
|
- Preserve `http.Hijacker` through the request-evidence middleware so audited,
|
||||||
|
authenticated WebSocket and other HTTP upgrade handlers can operate without
|
||||||
|
bypassing request logging. Successful upgrades are recorded as HTTP 101;
|
||||||
|
upgraded-protocol bytes remain outside HTTP body-byte accounting.
|
||||||
|
|
||||||
|
- Add revisioned active/archived lifecycles for organizations and teams,
|
||||||
|
invitation listing and revocation, membership suspension/removal, team-member
|
||||||
|
removal, and transactional organization-visible audit events.
|
||||||
|
- Make archived organizations and teams ineffective during authorization and
|
||||||
|
preserve the final active direct owner during membership changes.
|
||||||
|
- Allow invitations to carry one bounded direct role and reviewed team
|
||||||
|
memberships, applied atomically with single-use acceptance.
|
||||||
|
- Add an atomic password-to-passkey migration ceremony that stores the first
|
||||||
|
passkey, retires the password credential, revokes all sessions, and records
|
||||||
|
the migration audit event in one transaction.
|
||||||
|
|
||||||
## v0.1.0-preview.6 — 2026-08-24
|
## v0.1.0-preview.6 — 2026-08-24
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
Mozilla Public License Version 2.0
|
||||||
|
==================================
|
||||||
|
|
||||||
|
1. Definitions
|
||||||
|
--------------
|
||||||
|
|
||||||
|
1.1. "Contributor"
|
||||||
|
means each individual or legal entity that creates, contributes to
|
||||||
|
the creation of, or owns Covered Software.
|
||||||
|
|
||||||
|
1.2. "Contributor Version"
|
||||||
|
means the combination of the Contributions of others (if any) used
|
||||||
|
by a Contributor and that particular Contributor's Contribution.
|
||||||
|
|
||||||
|
1.3. "Contribution"
|
||||||
|
means Covered Software of a particular Contributor.
|
||||||
|
|
||||||
|
1.4. "Covered Software"
|
||||||
|
means Source Code Form to which the initial Contributor has attached
|
||||||
|
the notice in Exhibit A, the Executable Form of such Source Code
|
||||||
|
Form, and Modifications of such Source Code Form, in each case
|
||||||
|
including portions thereof.
|
||||||
|
|
||||||
|
1.5. "Incompatible With Secondary Licenses"
|
||||||
|
means
|
||||||
|
|
||||||
|
(a) that the initial Contributor has attached the notice described
|
||||||
|
in Exhibit B to the Covered Software; or
|
||||||
|
|
||||||
|
(b) that the Covered Software was made available under the terms of
|
||||||
|
version 1.1 or earlier of the License, but not also under the
|
||||||
|
terms of a Secondary License.
|
||||||
|
|
||||||
|
1.6. "Executable Form"
|
||||||
|
means any form of the work other than Source Code Form.
|
||||||
|
|
||||||
|
1.7. "Larger Work"
|
||||||
|
means a work that combines Covered Software with other material, in
|
||||||
|
a separate file or files, that is not Covered Software.
|
||||||
|
|
||||||
|
1.8. "License"
|
||||||
|
means this document.
|
||||||
|
|
||||||
|
1.9. "Licensable"
|
||||||
|
means having the right to grant, to the maximum extent possible,
|
||||||
|
whether at the time of the initial grant or subsequently, any and
|
||||||
|
all of the rights conveyed by this License.
|
||||||
|
|
||||||
|
1.10. "Modifications"
|
||||||
|
means any of the following:
|
||||||
|
|
||||||
|
(a) any file in Source Code Form that results from an addition to,
|
||||||
|
deletion from, or modification of the contents of Covered
|
||||||
|
Software; or
|
||||||
|
|
||||||
|
(b) any new file in Source Code Form that contains any Covered
|
||||||
|
Software.
|
||||||
|
|
||||||
|
1.11. "Patent Claims" of a Contributor
|
||||||
|
means any patent claim(s), including without limitation, method,
|
||||||
|
process, and apparatus claims, in any patent Licensable by such
|
||||||
|
Contributor that would be infringed, but for the grant of the
|
||||||
|
License, by the making, using, selling, offering for sale, having
|
||||||
|
made, import, or transfer of either its Contributions or its
|
||||||
|
Contributor Version.
|
||||||
|
|
||||||
|
1.12. "Secondary License"
|
||||||
|
means either the GNU General Public License, Version 2.0, the GNU
|
||||||
|
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||||
|
Public License, Version 3.0, or any later versions of those
|
||||||
|
licenses.
|
||||||
|
|
||||||
|
1.13. "Source Code Form"
|
||||||
|
means the form of the work preferred for making modifications.
|
||||||
|
|
||||||
|
1.14. "You" (or "Your")
|
||||||
|
means an individual or a legal entity exercising rights under this
|
||||||
|
License. For legal entities, "You" includes any entity that
|
||||||
|
controls, is controlled by, or is under common control with You. For
|
||||||
|
purposes of this definition, "control" means (a) the power, direct
|
||||||
|
or indirect, to cause the direction or management of such entity,
|
||||||
|
whether by contract or otherwise, or (b) ownership of more than
|
||||||
|
fifty percent (50%) of the outstanding shares or beneficial
|
||||||
|
ownership of such entity.
|
||||||
|
|
||||||
|
2. License Grants and Conditions
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
2.1. Grants
|
||||||
|
|
||||||
|
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||||
|
non-exclusive license:
|
||||||
|
|
||||||
|
(a) under intellectual property rights (other than patent or trademark)
|
||||||
|
Licensable by such Contributor to use, reproduce, make available,
|
||||||
|
modify, display, perform, distribute, and otherwise exploit its
|
||||||
|
Contributions, either on an unmodified basis, with Modifications, or
|
||||||
|
as part of a Larger Work; and
|
||||||
|
|
||||||
|
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||||
|
for sale, have made, import, and otherwise transfer either its
|
||||||
|
Contributions or its Contributor Version.
|
||||||
|
|
||||||
|
2.2. Effective Date
|
||||||
|
|
||||||
|
The licenses granted in Section 2.1 with respect to any Contribution
|
||||||
|
become effective for each Contribution on the date the Contributor first
|
||||||
|
distributes such Contribution.
|
||||||
|
|
||||||
|
2.3. Limitations on Grant Scope
|
||||||
|
|
||||||
|
The licenses granted in this Section 2 are the only rights granted under
|
||||||
|
this License. No additional rights or licenses will be implied from the
|
||||||
|
distribution or licensing of Covered Software under this License.
|
||||||
|
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||||
|
Contributor:
|
||||||
|
|
||||||
|
(a) for any code that a Contributor has removed from Covered Software;
|
||||||
|
or
|
||||||
|
|
||||||
|
(b) for infringements caused by: (i) Your and any other third party's
|
||||||
|
modifications of Covered Software, or (ii) the combination of its
|
||||||
|
Contributions with other software (except as part of its Contributor
|
||||||
|
Version); or
|
||||||
|
|
||||||
|
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||||
|
its Contributions.
|
||||||
|
|
||||||
|
This License does not grant any rights in the trademarks, service marks,
|
||||||
|
or logos of any Contributor (except as may be necessary to comply with
|
||||||
|
the notice requirements in Section 3.4).
|
||||||
|
|
||||||
|
2.4. Subsequent Licenses
|
||||||
|
|
||||||
|
No Contributor makes additional grants as a result of Your choice to
|
||||||
|
distribute the Covered Software under a subsequent version of this
|
||||||
|
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||||
|
permitted under the terms of Section 3.3).
|
||||||
|
|
||||||
|
2.5. Representation
|
||||||
|
|
||||||
|
Each Contributor represents that the Contributor believes its
|
||||||
|
Contributions are its original creation(s) or it has sufficient rights
|
||||||
|
to grant the rights to its Contributions conveyed by this License.
|
||||||
|
|
||||||
|
2.6. Fair Use
|
||||||
|
|
||||||
|
This License is not intended to limit any rights You have under
|
||||||
|
applicable copyright doctrines of fair use, fair dealing, or other
|
||||||
|
equivalents.
|
||||||
|
|
||||||
|
2.7. Conditions
|
||||||
|
|
||||||
|
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||||
|
in Section 2.1.
|
||||||
|
|
||||||
|
3. Responsibilities
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
3.1. Distribution of Source Form
|
||||||
|
|
||||||
|
All distribution of Covered Software in Source Code Form, including any
|
||||||
|
Modifications that You create or to which You contribute, must be under
|
||||||
|
the terms of this License. You must inform recipients that the Source
|
||||||
|
Code Form of the Covered Software is governed by the terms of this
|
||||||
|
License, and how they can obtain a copy of this License. You may not
|
||||||
|
attempt to alter or restrict the recipients' rights in the Source Code
|
||||||
|
Form.
|
||||||
|
|
||||||
|
3.2. Distribution of Executable Form
|
||||||
|
|
||||||
|
If You distribute Covered Software in Executable Form then:
|
||||||
|
|
||||||
|
(a) such Covered Software must also be made available in Source Code
|
||||||
|
Form, as described in Section 3.1, and You must inform recipients of
|
||||||
|
the Executable Form how they can obtain a copy of such Source Code
|
||||||
|
Form by reasonable means in a timely manner, at a charge no more
|
||||||
|
than the cost of distribution to the recipient; and
|
||||||
|
|
||||||
|
(b) You may distribute such Executable Form under the terms of this
|
||||||
|
License, or sublicense it under different terms, provided that the
|
||||||
|
license for the Executable Form does not attempt to limit or alter
|
||||||
|
the recipients' rights in the Source Code Form under this License.
|
||||||
|
|
||||||
|
3.3. Distribution of a Larger Work
|
||||||
|
|
||||||
|
You may create and distribute a Larger Work under terms of Your choice,
|
||||||
|
provided that You also comply with the requirements of this License for
|
||||||
|
the Covered Software. If the Larger Work is a combination of Covered
|
||||||
|
Software with a work governed by one or more Secondary Licenses, and the
|
||||||
|
Covered Software is not Incompatible With Secondary Licenses, this
|
||||||
|
License permits You to additionally distribute such Covered Software
|
||||||
|
under the terms of such Secondary License(s), so that the recipient of
|
||||||
|
the Larger Work may, at their option, further distribute the Covered
|
||||||
|
Software under the terms of either this License or such Secondary
|
||||||
|
License(s).
|
||||||
|
|
||||||
|
3.4. Notices
|
||||||
|
|
||||||
|
You may not remove or alter the substance of any license notices
|
||||||
|
(including copyright notices, patent notices, disclaimers of warranty,
|
||||||
|
or limitations of liability) contained within the Source Code Form of
|
||||||
|
the Covered Software, except that You may alter any license notices to
|
||||||
|
the extent required to remedy known factual inaccuracies.
|
||||||
|
|
||||||
|
3.5. Application of Additional Terms
|
||||||
|
|
||||||
|
You may choose to offer, and to charge a fee for, warranty, support,
|
||||||
|
indemnity or liability obligations to one or more recipients of Covered
|
||||||
|
Software. However, You may do so only on Your own behalf, and not on
|
||||||
|
behalf of any Contributor. You must make it absolutely clear that any
|
||||||
|
such warranty, support, indemnity, or liability obligation is offered by
|
||||||
|
You alone, and You hereby agree to indemnify every Contributor for any
|
||||||
|
liability incurred by such Contributor as a result of warranty, support,
|
||||||
|
indemnity or liability terms You offer. You may include additional
|
||||||
|
disclaimers of warranty and limitations of liability specific to any
|
||||||
|
jurisdiction.
|
||||||
|
|
||||||
|
4. Inability to Comply Due to Statute or Regulation
|
||||||
|
---------------------------------------------------
|
||||||
|
|
||||||
|
If it is impossible for You to comply with any of the terms of this
|
||||||
|
License with respect to some or all of the Covered Software due to
|
||||||
|
statute, judicial order, or regulation then You must: (a) comply with
|
||||||
|
the terms of this License to the maximum extent possible; and (b)
|
||||||
|
describe the limitations and the code they affect. Such description must
|
||||||
|
be placed in a text file included with all distributions of the Covered
|
||||||
|
Software under this License. Except to the extent prohibited by statute
|
||||||
|
or regulation, such description must be sufficiently detailed for a
|
||||||
|
recipient of ordinary skill to be able to understand it.
|
||||||
|
|
||||||
|
5. Termination
|
||||||
|
--------------
|
||||||
|
|
||||||
|
5.1. The rights granted under this License will terminate automatically
|
||||||
|
if You fail to comply with any of its terms. However, if You become
|
||||||
|
compliant, then the rights granted under this License from a particular
|
||||||
|
Contributor are reinstated (a) provisionally, unless and until such
|
||||||
|
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||||
|
ongoing basis, if such Contributor fails to notify You of the
|
||||||
|
non-compliance by some reasonable means prior to 60 days after You have
|
||||||
|
come back into compliance. Moreover, Your grants from a particular
|
||||||
|
Contributor are reinstated on an ongoing basis if such Contributor
|
||||||
|
notifies You of the non-compliance by some reasonable means, this is the
|
||||||
|
first time You have received notice of non-compliance with this License
|
||||||
|
from such Contributor, and You become compliant prior to 30 days after
|
||||||
|
Your receipt of the notice.
|
||||||
|
|
||||||
|
5.2. If You initiate litigation against any entity by asserting a patent
|
||||||
|
infringement claim (excluding declaratory judgment actions,
|
||||||
|
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||||
|
directly or indirectly infringes any patent, then the rights granted to
|
||||||
|
You by any and all Contributors for the Covered Software under Section
|
||||||
|
2.1 of this License shall terminate.
|
||||||
|
|
||||||
|
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||||
|
end user license agreements (excluding distributors and resellers) which
|
||||||
|
have been validly granted by You or Your distributors under this License
|
||||||
|
prior to termination shall survive termination.
|
||||||
|
|
||||||
|
************************************************************************
|
||||||
|
* *
|
||||||
|
* 6. Disclaimer of Warranty *
|
||||||
|
* ------------------------- *
|
||||||
|
* *
|
||||||
|
* Covered Software is provided under this License on an "as is" *
|
||||||
|
* basis, without warranty of any kind, either expressed, implied, or *
|
||||||
|
* statutory, including, without limitation, warranties that the *
|
||||||
|
* Covered Software is free of defects, merchantable, fit for a *
|
||||||
|
* particular purpose or non-infringing. The entire risk as to the *
|
||||||
|
* quality and performance of the Covered Software is with You. *
|
||||||
|
* Should any Covered Software prove defective in any respect, You *
|
||||||
|
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||||
|
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||||
|
* essential part of this License. No use of any Covered Software is *
|
||||||
|
* authorized under this License except under this disclaimer. *
|
||||||
|
* *
|
||||||
|
************************************************************************
|
||||||
|
|
||||||
|
************************************************************************
|
||||||
|
* *
|
||||||
|
* 7. Limitation of Liability *
|
||||||
|
* -------------------------- *
|
||||||
|
* *
|
||||||
|
* Under no circumstances and under no legal theory, whether tort *
|
||||||
|
* (including negligence), contract, or otherwise, shall any *
|
||||||
|
* Contributor, or anyone who distributes Covered Software as *
|
||||||
|
* permitted above, be liable to You for any direct, indirect, *
|
||||||
|
* special, incidental, or consequential damages of any character *
|
||||||
|
* including, without limitation, damages for lost profits, loss of *
|
||||||
|
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||||
|
* and all other commercial damages or losses, even if such party *
|
||||||
|
* shall have been informed of the possibility of such damages. This *
|
||||||
|
* limitation of liability shall not apply to liability for death or *
|
||||||
|
* personal injury resulting from such party's negligence to the *
|
||||||
|
* extent applicable law prohibits such limitation. Some *
|
||||||
|
* jurisdictions do not allow the exclusion or limitation of *
|
||||||
|
* incidental or consequential damages, so this exclusion and *
|
||||||
|
* limitation may not apply to You. *
|
||||||
|
* *
|
||||||
|
************************************************************************
|
||||||
|
|
||||||
|
8. Litigation
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Any litigation relating to this License may be brought only in the
|
||||||
|
courts of a jurisdiction where the defendant maintains its principal
|
||||||
|
place of business and such litigation shall be governed by laws of that
|
||||||
|
jurisdiction, without reference to its conflict-of-law provisions.
|
||||||
|
Nothing in this Section shall prevent a party's ability to bring
|
||||||
|
cross-claims or counter-claims.
|
||||||
|
|
||||||
|
9. Miscellaneous
|
||||||
|
----------------
|
||||||
|
|
||||||
|
This License represents the complete agreement concerning the subject
|
||||||
|
matter hereof. If any provision of this License is held to be
|
||||||
|
unenforceable, such provision shall be reformed only to the extent
|
||||||
|
necessary to make it enforceable. Any law or regulation which provides
|
||||||
|
that the language of a contract shall be construed against the drafter
|
||||||
|
shall not be used to construe this License against a Contributor.
|
||||||
|
|
||||||
|
10. Versions of the License
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
10.1. New Versions
|
||||||
|
|
||||||
|
Mozilla Foundation is the license steward. Except as provided in Section
|
||||||
|
10.3, no one other than the license steward has the right to modify or
|
||||||
|
publish new versions of this License. Each version will be given a
|
||||||
|
distinguishing version number.
|
||||||
|
|
||||||
|
10.2. Effect of New Versions
|
||||||
|
|
||||||
|
You may distribute the Covered Software under the terms of the version
|
||||||
|
of the License under which You originally received the Covered Software,
|
||||||
|
or under the terms of any subsequent version published by the license
|
||||||
|
steward.
|
||||||
|
|
||||||
|
10.3. Modified Versions
|
||||||
|
|
||||||
|
If you create software not governed by this License, and you want to
|
||||||
|
create a new license for such software, you may create and use a
|
||||||
|
modified version of this License if you rename the license and remove
|
||||||
|
any references to the name of the license steward (except to note that
|
||||||
|
such modified license differs from this License).
|
||||||
|
|
||||||
|
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||||
|
Licenses
|
||||||
|
|
||||||
|
If You choose to distribute Source Code Form that is Incompatible With
|
||||||
|
Secondary Licenses under the terms of this version of the License, the
|
||||||
|
notice described in Exhibit B of this License must be attached.
|
||||||
|
|
||||||
|
Exhibit A - Source Code Form License Notice
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
If it is not possible or desirable to put the notice in a particular
|
||||||
|
file, then You may include the notice in a location (such as a LICENSE
|
||||||
|
file in a relevant directory) where a recipient would be likely to look
|
||||||
|
for such a notice.
|
||||||
|
|
||||||
|
You may add additional accurate notices of copyright ownership.
|
||||||
|
|
||||||
|
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||||
|
---------------------------------------------------------
|
||||||
|
|
||||||
|
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||||
|
defined by the Mozilla Public License, v. 2.0.
|
||||||
@@ -14,3 +14,7 @@ fails closed on missing or misplaced identifiers.
|
|||||||
Full texts are in `LICENSES/`. Combining these MPL-covered packages with an
|
Full texts are in `LICENSES/`. Combining these MPL-covered packages with an
|
||||||
application does not change the licence of the application's own files; changes
|
application does not change the licence of the application's own files; changes
|
||||||
to covered files remain subject to the MPL. This summary is not legal advice.
|
to covered files remain subject to the MPL. This summary is not legal advice.
|
||||||
|
|
||||||
|
The root [`LICENSE`](LICENSE) contains the default MPL-2.0 text for package
|
||||||
|
indexers and repository tooling. More specific file-level SPDX identifiers in
|
||||||
|
the paths above remain authoritative.
|
||||||
|
|||||||
@@ -2,106 +2,150 @@
|
|||||||
|
|
||||||
# Gamertan Web Foundations
|
# Gamertan Web Foundations
|
||||||
|
|
||||||
> Status: `v0.1.0-preview.6` public preview. APIs may change before a stable
|
[](https://pkg.go.dev/gamertan.com/web)
|
||||||
> release; Linux is the maintained release platform.
|
[](https://gitea.speelman.ca/gamertan/web/actions?workflow=verify.yml)
|
||||||
|
|
||||||
Small, composable Go packages for the unglamorous boundaries of a careful web
|
**Security-conscious building blocks for ordinary `net/http` applications.**
|
||||||
application: request identity, structured request logs, browser security,
|
|
||||||
passwords, passkeys, sessions, permissions, SQLite persistence, and private
|
|
||||||
analytics.
|
|
||||||
|
|
||||||
This is a toolkit, not an application framework. Your application keeps its
|
Web Foundations provides small, composable Go packages for the unglamorous
|
||||||
router, HTTP policy, HTML, authorization decisions, cache behavior, and
|
boundaries of a careful web application: request identity, structured request
|
||||||
deployment. Each package works with `net/http` and can be adopted independently.
|
evidence, browser security, authentication, passkeys, permissions,
|
||||||
|
organizations, SQLite persistence, abuse controls, and private analytics.
|
||||||
|
|
||||||
The first preview targets modest Linux servers, local files, SQLite, and normal
|
It is a toolkit, not an application framework. Your application keeps its
|
||||||
Go binaries. It requires no Redis, message broker, hosted identity provider,
|
router, handlers, HTML, authorization decisions, cache behavior, and
|
||||||
telemetry service, or JavaScript framework.
|
deployment. Adopt one boundary at a time; Go compiles and links only the
|
||||||
|
packages you import.
|
||||||
|
|
||||||
|
> **Public preview:** `v0.1.0-preview.25`. APIs may change before a stable
|
||||||
|
> release. Linux is the maintained release platform.
|
||||||
|
|
||||||
|
## Why Web Foundations?
|
||||||
|
|
||||||
|
| Design promise | What it means in an application |
|
||||||
|
| --- | --- |
|
||||||
|
| `net/http` native | Keep the standard router or any compatible router; there is no framework lifecycle. |
|
||||||
|
| Explicit security boundaries | Trusted proxies, sensitive log fields, browser origins, and scoped authority are configured deliberately. |
|
||||||
|
| Bounded and fail-closed | Untrusted inputs are size-limited, and security-critical configuration or storage failures do not quietly weaken policy. |
|
||||||
|
| Storage-neutral core | Interfaces separate identity and access policy from the optional no-CGO SQLite adapter. |
|
||||||
|
| Self-hosted by default | No Redis, message broker, hosted identity provider, telemetry service, or JavaScript framework is required. |
|
||||||
|
|
||||||
|
## Start with one boundary
|
||||||
|
|
||||||
|
| Application need | Begin with |
|
||||||
|
| --- | --- |
|
||||||
|
| Request IDs and trustworthy client addresses | [`requestmeta`](requestmeta) |
|
||||||
|
| Bounded structured request evidence | [`requestmeta`](requestmeta) + [`requestlog`](requestlog) |
|
||||||
|
| Browser and HTTP security primitives | [`websec`](websec) |
|
||||||
|
| Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) |
|
||||||
|
| Atomic password-plus-passkey registration | [`account`](account) |
|
||||||
|
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
|
||||||
|
| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) |
|
||||||
|
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
|
||||||
|
| Private SQLite persistence | [`authsqlite`](authsqlite) |
|
||||||
|
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
|
||||||
|
| Organizations, teams, and invitations | [`organizations`](organizations) |
|
||||||
|
| Organization-scoped roles and temporary access | [`access`](access) |
|
||||||
|
| Application-classified request abuse | [`abuse`](abuse) |
|
||||||
|
| Disposable request-log summaries | [`analytics`](analytics) |
|
||||||
|
|
||||||
|
The [getting-started guide](docs/GETTING_STARTED.md) explains what each package
|
||||||
|
owns—and, just as importantly, what remains application policy.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Pin the preview in an application module, then import only the packages that
|
Pin the preview in an application module:
|
||||||
application needs:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web@v0.1.0-preview.6
|
go get gamertan.com/web@v0.1.0-preview.25
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
An application may also 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.6
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.25
|
||||||
```
|
```
|
||||||
|
|
||||||
The version belongs to the `gamertan.com/web` module. Go compiles and links
|
The version belongs to the `gamertan.com/web` module. See the
|
||||||
only the packages the application imports. See the [getting-started guide](docs/GETTING_STARTED.md)
|
[module-boundary policy](docs/MODULES.md) before selecting a first slice.
|
||||||
and [module-boundary policy](docs/MODULES.md) before choosing a first slice.
|
|
||||||
|
|
||||||
Canonical source, issues, security policy, and release notes live on
|
## Compose a request path
|
||||||
[Gamertan Gitea](https://gitea.speelman.ca/gamertan/web). GitHub is a read-only
|
|
||||||
discovery snapshot rather than a second release origin.
|
|
||||||
|
|
||||||
Linux is the required and supported release platform. WSL may be used as a
|
Build middleware from the application outward. The request metadata resolver
|
||||||
Linux development environment. Native Windows is not a release gate or support
|
is outermost so every package inside it observes the same request identity:
|
||||||
promise; downstream users may evaluate the ordinary Go packages elsewhere
|
|
||||||
without turning that portability into a maintained compatibility claim.
|
|
||||||
|
|
||||||
## Packages
|
```text
|
||||||
|
request
|
||||||
|
└─ requestmeta ─ websec ─ requestlog ─ your router and handlers
|
||||||
|
```
|
||||||
|
|
||||||
- [`requestmeta`](requestmeta): trusted-proxy resolution, HTTPS/origin metadata,
|
```go
|
||||||
and request IDs.
|
var handler http.Handler = router
|
||||||
- [`requestlog`](requestlog): bounded versioned records, middleware, sinks, and
|
handler = requestlog.Middleware(sink, logPolicy)(handler)
|
||||||
private JSONL.
|
handler = websec.Headers(headerPolicy)(handler)
|
||||||
- [`websec`](websec): headers, origin checks, CSRF, redirects, body limits, and
|
handler = resolver.Middleware(handler)
|
||||||
rate limits.
|
```
|
||||||
- [`abuse`](abuse): application-classified request abuse with pluggable persistence.
|
|
||||||
- [`auth`](auth), [`authhttp`](authhttp), and [`authsqlite`](authsqlite):
|
|
||||||
passwords, forced first-login rotation, local administrative recovery,
|
|
||||||
session revocation, platform-level permissions, cookies, and a no-CGO SQLite
|
|
||||||
adapter.
|
|
||||||
- [`authwebauthn`](authwebauthn): passkey-only registration, discoverable
|
|
||||||
login, fresh-operation approval, local recovery tokens, and an ES256-first
|
|
||||||
WebAuthn policy. See the [passkey integration guide](docs/PASSKEYS.md).
|
|
||||||
- [`organizations`](organizations) and [`access`](access): organizations,
|
|
||||||
teams, invitations, resource hierarchy, scoped roles, and audited temporary
|
|
||||||
access without turning platform operation into tenant-data access.
|
|
||||||
- [`analytics`](analytics): safe and sensitive aggregate projections over request
|
|
||||||
records.
|
|
||||||
|
|
||||||
The copyable starter under `starters/basic` demonstrates the packages without
|
The copyable [`starters/basic`](starters/basic) server demonstrates that
|
||||||
turning them into a router or template system.
|
composition with loopback binding, graceful shutdown, and optional private
|
||||||
|
JSONL logging.
|
||||||
|
|
||||||
|
## Identity and access
|
||||||
|
|
||||||
|
- [`auth`](auth) defines storage-neutral users, password credentials, opaque
|
||||||
|
sessions, platform permissions, and audit events.
|
||||||
|
- [`account`](account) composes the first password, printable recovery codes,
|
||||||
|
personal organization, and owner access as one registration transaction,
|
||||||
|
optionally including an initial passkey.
|
||||||
|
- [`authhttp`](authhttp) connects those sessions to secure browser cookies and
|
||||||
|
request context without owning login routes or pages.
|
||||||
|
- [`authwebauthn`](authwebauthn) provides discoverable passkey login,
|
||||||
|
enrollment, operation-bound fresh approval, and bounded recovery.
|
||||||
|
- [`authrecovery`](authrecovery) supports printable self-service recovery and
|
||||||
|
a separate owner-assisted flow that atomically replaces compromised account
|
||||||
|
credentials while writing both identity and organization-visible audits.
|
||||||
|
- [`organizations`](organizations) and [`access`](access) keep platform
|
||||||
|
operation separate from organization-data authority while supporting teams,
|
||||||
|
invitations, scoped roles, and audited temporary access.
|
||||||
|
|
||||||
|
See the [passkey integration guide](docs/PASSKEYS.md) and
|
||||||
|
[organization/access model](docs/ORGANIZATIONS.md) before exposing account or
|
||||||
|
administration routes.
|
||||||
|
|
||||||
|
## Security and assurance
|
||||||
|
|
||||||
|
Client addresses are accepted from forwarding headers only when the immediate
|
||||||
|
peer and every skipped proxy are explicitly trusted. Sensitive request fields
|
||||||
|
are off by default. Cryptographic entropy failures fail closed. Logs and
|
||||||
|
account databases remain private application data and never belong in source
|
||||||
|
releases.
|
||||||
|
|
||||||
|
Every change is checked with formatting, tests, the race detector, vet,
|
||||||
|
dependency policy, licence policy, public-snapshot allowlisting, and a
|
||||||
|
reproducible starter build. Scheduled assurance adds vulnerability scanning and
|
||||||
|
bounded fuzz campaigns.
|
||||||
|
|
||||||
|
Read [SECURITY.md](SECURITY.md), the [threat model](docs/THREAT_MODEL.md),
|
||||||
|
[adoption contract](docs/ADOPTION.md), and
|
||||||
|
[dependency boundary](docs/DEPENDENCIES.md) before production adoption.
|
||||||
|
|
||||||
## HTML and templates
|
## HTML and templates
|
||||||
|
|
||||||
Web Foundations deliberately does not provide a template language. Sandwich
|
Web Foundations deliberately does not provide a template language. Sandwich
|
||||||
Hime is the preferred companion for Gamertan applications that want HTML-first,
|
Hime is the preferred companion for Gamertan applications that want HTML-first,
|
||||||
typed, ahead-of-time Go templates. The two projects remain independently
|
typed, ahead-of-time Go templates. The projects remain independently usable.
|
||||||
usable: this module does not import the `sando` runtime, and Sandwich Hime does
|
|
||||||
not own middleware, authentication, logging, routing, or deployment.
|
|
||||||
|
|
||||||
See [HTML with Sandwich Hime](docs/SANDWICH_HIME.md), then follow the official
|
See [HTML with Sandwich Hime](docs/SANDWICH_HIME.md) and the official
|
||||||
[first site tutorial](https://sandwichhime.com/docs/tutorial/) and
|
[first-site tutorial](https://sandwichhime.com/docs/tutorial/).
|
||||||
[application integration tutorial](https://sandwichhime.com/docs/tutorial/application/).
|
|
||||||
|
|
||||||
## Security boundary
|
## Source, support, and licensing
|
||||||
|
|
||||||
Client addresses are accepted from forwarding headers only when the immediate
|
Canonical source, issues, security policy, and release notes live on
|
||||||
peer and every skipped proxy are explicitly trusted. Sensitive request fields
|
[Speelman Forge](https://gitea.speelman.ca/gamertan/web). GitHub is a read-only
|
||||||
are off by default. Cryptographic entropy failures fail closed. Logs and account
|
discovery snapshot rather than a second release origin.
|
||||||
databases remain private application data and never belong in source releases.
|
|
||||||
|
|
||||||
See [SECURITY.md](SECURITY.md), [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md),
|
The libraries and adapters are MPL-2.0. Starters and reusable examples are
|
||||||
the [application adoption contract](docs/ADOPTION.md), and
|
0BSD. Future standalone services and operational machinery are
|
||||||
[docs/SERVICES_ROADMAP.md](docs/SERVICES_ROADMAP.md).
|
AGPL-3.0-only. Exact file-level SPDX identifiers remain authoritative; see the
|
||||||
|
[licensing map](LICENSES.md) and [third-party notices](THIRD_PARTY_NOTICES.md).
|
||||||
## Licensing
|
|
||||||
|
|
||||||
This is a multi-license repository with exact file-level SPDX identifiers:
|
|
||||||
|
|
||||||
- embeddable packages and adapters: MPL-2.0;
|
|
||||||
- future standalone network services and operational machinery: AGPL-3.0-only;
|
|
||||||
- starters, examples, and reusable configuration: 0BSD.
|
|
||||||
|
|
||||||
See [LICENSES.md](LICENSES.md). No standalone auth or logging server is included
|
|
||||||
in this preview.
|
|
||||||
|
|||||||
+89
-5
@@ -18,8 +18,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
ErrLastOwner = errors.New("access: the last active direct owner must be preserved")
|
||||||
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
|
ErrOwnerAuthority = errors.New("access: a current direct owner must approve owner role changes")
|
||||||
|
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
|
type SubjectKind string
|
||||||
@@ -116,6 +120,8 @@ type Repository interface {
|
|||||||
Grant(context.Context, Binding) error
|
Grant(context.Context, Binding) error
|
||||||
Revoke(context.Context, string, string, time.Time) error
|
Revoke(context.Context, string, string, time.Time) error
|
||||||
EffectiveBindings(context.Context, string, string) ([]Binding, 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
|
CreateBreakGlass(context.Context, BreakGlass, AuditEvent) error
|
||||||
ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error)
|
ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error)
|
||||||
AppendAccessAudit(context.Context, AuditEvent) error
|
AppendAccessAudit(context.Context, AuditEvent) error
|
||||||
@@ -123,8 +129,9 @@ type Repository interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Random io.Reader
|
Random io.Reader
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
OwnerRole string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -132,6 +139,7 @@ type Service struct {
|
|||||||
policy Policy
|
policy Policy
|
||||||
random io.Reader
|
random io.Reader
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
ownerRole string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(repository Repository, policy Policy, options Options) (*Service, error) {
|
func New(repository Repository, policy Policy, options Options) (*Service, error) {
|
||||||
@@ -147,7 +155,12 @@ func New(repository Repository, policy Policy, options Options) (*Service, error
|
|||||||
if options.Now == nil {
|
if options.Now == nil {
|
||||||
options.Now = time.Now
|
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 {
|
func (service *Service) Seed(ctx context.Context) error {
|
||||||
@@ -183,6 +196,63 @@ func (service *Service) Grant(ctx context.Context, input Grant) (Binding, error)
|
|||||||
return binding, nil
|
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 requires a current active
|
||||||
|
// direct owner for any change to or from that role and 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 {
|
type Decision struct {
|
||||||
Allowed bool
|
Allowed bool
|
||||||
Source string
|
Source string
|
||||||
@@ -265,6 +335,20 @@ func randomID(random io.Reader) (string, error) {
|
|||||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
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 {
|
func text(value string, limit int, emptyOK bool) bool {
|
||||||
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
|
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-2
@@ -4,6 +4,8 @@ package access
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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 {
|
type repositoryStub struct {
|
||||||
bindings []Binding
|
bindings []Binding
|
||||||
breakGlass []BreakGlass
|
breakGlass []BreakGlass
|
||||||
|
organizationUser []Binding
|
||||||
|
replacedExpected []string
|
||||||
|
replacedBinding Binding
|
||||||
|
replacedOwnerRole string
|
||||||
|
replacedAccessAudit AuditEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*repositoryStub) SeedAccessPolicy(context.Context, Policy) error { return nil }
|
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) {
|
func (repository *repositoryStub) EffectiveBindings(context.Context, string, string) ([]Binding, error) {
|
||||||
return repository.bindings, nil
|
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 {
|
func (repository *repositoryStub) CreateBreakGlass(_ context.Context, grant BreakGlass, _ AuditEvent) error {
|
||||||
repository.breakGlass = []BreakGlass{grant}
|
repository.breakGlass = []BreakGlass{grant}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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,338 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Package account orchestrates atomic account registration. Email is the
|
||||||
|
// canonical sign-in identifier; username remains the stable public/profile
|
||||||
|
// identity. Applications may finish with password-only base access or include
|
||||||
|
// an initial passkey when their onboarding policy requires one.
|
||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/authrecovery"
|
||||||
|
"gamertan.com/web/authwebauthn"
|
||||||
|
"gamertan.com/web/organizations"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrRegistrationNotFound = errors.New("account: registration not found")
|
||||||
|
ErrPasskeysUnavailable = errors.New("account: passkeys are unavailable")
|
||||||
|
usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Registration struct {
|
||||||
|
Digest [32]byte
|
||||||
|
User auth.User
|
||||||
|
CreatedAt, ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegistrationCompletion struct {
|
||||||
|
Credential *authwebauthn.Credential
|
||||||
|
RecoveryDigests [][32]byte
|
||||||
|
Organization organizations.Organization
|
||||||
|
Membership organizations.Membership
|
||||||
|
OwnerBinding access.Binding
|
||||||
|
AuthAudit auth.AuditEvent
|
||||||
|
OrganizationAudit organizations.AuditEvent
|
||||||
|
AccessAudit access.AuditEvent
|
||||||
|
CompletedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
CreateRegistration(context.Context, Registration, string, auth.AuditEvent) error
|
||||||
|
Registration(context.Context, [32]byte, time.Time) (Registration, error)
|
||||||
|
CompleteRegistration(context.Context, [32]byte, RegistrationCompletion) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Passkeys interface {
|
||||||
|
BeginAccountRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
|
||||||
|
FinishAccountRegistration(context.Context, string, []byte, []byte, authwebauthn.RegistrationCommit) (authwebauthn.Credential, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sessions interface {
|
||||||
|
IssueSession(context.Context, string, time.Duration) (string, auth.Principal, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Random io.Reader
|
||||||
|
Now func() time.Time
|
||||||
|
RegistrationTTL time.Duration
|
||||||
|
SessionLifetime time.Duration
|
||||||
|
RecoveryCodes int
|
||||||
|
OwnerRole string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repository Repository
|
||||||
|
passkeys Passkeys
|
||||||
|
sessions Sessions
|
||||||
|
random io.Reader
|
||||||
|
now func() time.Time
|
||||||
|
draftTTL time.Duration
|
||||||
|
sessionTTL time.Duration
|
||||||
|
codeCount int
|
||||||
|
ownerRole string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repository Repository, passkeys Passkeys, sessions Sessions, options Options) (*Service, error) {
|
||||||
|
if repository == nil || sessions == nil {
|
||||||
|
return nil, errors.New("account: repository and sessions are required")
|
||||||
|
}
|
||||||
|
if options.Random == nil {
|
||||||
|
options.Random = rand.Reader
|
||||||
|
}
|
||||||
|
if options.Now == nil {
|
||||||
|
options.Now = time.Now
|
||||||
|
}
|
||||||
|
if options.RegistrationTTL == 0 {
|
||||||
|
options.RegistrationTTL = 15 * time.Minute
|
||||||
|
}
|
||||||
|
if options.SessionLifetime == 0 {
|
||||||
|
options.SessionLifetime = 12 * time.Hour
|
||||||
|
}
|
||||||
|
if options.RecoveryCodes == 0 {
|
||||||
|
options.RecoveryCodes = authrecovery.DefaultCodeCount
|
||||||
|
}
|
||||||
|
if options.OwnerRole == "" {
|
||||||
|
options.OwnerRole = "owner"
|
||||||
|
}
|
||||||
|
if options.RegistrationTTL < 5*time.Minute || options.RegistrationTTL > time.Hour || options.SessionLifetime < 5*time.Minute || options.SessionLifetime > 30*24*time.Hour || options.RecoveryCodes < 5 || options.RecoveryCodes > 20 || !roleName(options.OwnerRole) {
|
||||||
|
return nil, errors.New("account: invalid registration policy")
|
||||||
|
}
|
||||||
|
return &Service{repository: repository, passkeys: passkeys, sessions: sessions, random: options.Random, now: options.Now, draftTTL: options.RegistrationTTL, sessionTTL: options.SessionLifetime, codeCount: options.RecoveryCodes, ownerRole: options.OwnerRole}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type StartInput struct {
|
||||||
|
Email, Username, DisplayName, Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StartResult struct {
|
||||||
|
RegistrationToken string
|
||||||
|
User auth.User
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start validates and stores a bounded pending registration. The returned
|
||||||
|
// secret is displayed only to the same browser flow and binds every following
|
||||||
|
// ceremony to this draft.
|
||||||
|
func (service *Service) Start(ctx context.Context, input StartInput) (StartResult, error) {
|
||||||
|
email, err := canonicalEmail(input.Email)
|
||||||
|
if err != nil {
|
||||||
|
return StartResult{}, err
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(input.Username)
|
||||||
|
displayName := strings.TrimSpace(input.DisplayName)
|
||||||
|
if !usernamePattern.MatchString(username) || displayName == "" || len(displayName) > 128 || strings.ContainsAny(displayName, "\x00\r\n") {
|
||||||
|
return StartResult{}, errors.New("account: invalid profile")
|
||||||
|
}
|
||||||
|
passwordHash, err := auth.HashPasswordWithRandom(input.Password, service.random)
|
||||||
|
if err != nil {
|
||||||
|
return StartResult{}, err
|
||||||
|
}
|
||||||
|
userID, err := service.token(18)
|
||||||
|
if err != nil {
|
||||||
|
return StartResult{}, err
|
||||||
|
}
|
||||||
|
rawToken, err := service.token(32)
|
||||||
|
if err != nil {
|
||||||
|
return StartResult{}, err
|
||||||
|
}
|
||||||
|
now := service.now().UTC()
|
||||||
|
user := auth.User{ID: userID, Username: username, Email: email, DisplayName: displayName, Status: "active", RegistrationPending: true, CreatedAt: now, UpdatedAt: now}
|
||||||
|
registration := Registration{Digest: sha256.Sum256([]byte(rawToken)), User: user, CreatedAt: now, ExpiresAt: now.Add(service.draftTTL)}
|
||||||
|
audit, err := service.authAudit(user.ID, "auth.account.registration.start", "A public account registration was started.")
|
||||||
|
if err != nil {
|
||||||
|
return StartResult{}, err
|
||||||
|
}
|
||||||
|
if err = service.repository.CreateRegistration(ctx, registration, passwordHash, audit); err != nil {
|
||||||
|
return StartResult{}, err
|
||||||
|
}
|
||||||
|
return StartResult{RegistrationToken: rawToken, User: user, ExpiresAt: registration.ExpiresAt}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) BeginPasskey(ctx context.Context, registrationToken, label string) (authwebauthn.BeginResult, error) {
|
||||||
|
if service.passkeys == nil {
|
||||||
|
return authwebauthn.BeginResult{}, ErrPasskeysUnavailable
|
||||||
|
}
|
||||||
|
registration, err := service.registration(ctx, registrationToken)
|
||||||
|
if err != nil {
|
||||||
|
return authwebauthn.BeginResult{}, err
|
||||||
|
}
|
||||||
|
return service.passkeys.BeginAccountRegistration(ctx, registration.User.ID, label, []byte(registrationToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinishResult struct {
|
||||||
|
User auth.User
|
||||||
|
Organization organizations.Organization
|
||||||
|
RecoveryCodes []string
|
||||||
|
SessionToken string
|
||||||
|
Principal auth.Principal
|
||||||
|
PasskeyCredential authwebauthn.Credential
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishPassword activates a base account without requiring WebAuthn. The
|
||||||
|
// application can require an operation-bound passkey assertion later for
|
||||||
|
// sensitive permissions.
|
||||||
|
func (service *Service) FinishPassword(ctx context.Context, registrationToken string) (FinishResult, error) {
|
||||||
|
registration, err := service.registration(ctx, registrationToken)
|
||||||
|
if err != nil {
|
||||||
|
return FinishResult{}, err
|
||||||
|
}
|
||||||
|
codes, recoveryDigests, err := authrecovery.GenerateCodeSet(service.random, service.codeCount)
|
||||||
|
if err != nil {
|
||||||
|
return FinishResult{}, err
|
||||||
|
}
|
||||||
|
completion, err := service.completion(registration, recoveryDigests)
|
||||||
|
if err != nil {
|
||||||
|
return FinishResult{}, err
|
||||||
|
}
|
||||||
|
completion.AuthAudit, err = service.authAudit(registration.User.ID, "auth.account.registration.complete", "The password-authenticated account registration was completed.")
|
||||||
|
if err != nil {
|
||||||
|
return FinishResult{}, err
|
||||||
|
}
|
||||||
|
if err = service.repository.CompleteRegistration(ctx, registration.Digest, completion); err != nil {
|
||||||
|
return FinishResult{}, err
|
||||||
|
}
|
||||||
|
return service.finishSession(ctx, registration, completion, codes, authwebauthn.Credential{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishWithPasskey completes the same atomic account transaction while also
|
||||||
|
// storing a verified initial passkey.
|
||||||
|
func (service *Service) FinishWithPasskey(ctx context.Context, registrationToken, ceremonyToken string, response []byte) (FinishResult, error) {
|
||||||
|
if service.passkeys == nil {
|
||||||
|
return FinishResult{}, ErrPasskeysUnavailable
|
||||||
|
}
|
||||||
|
registration, err := service.registration(ctx, registrationToken)
|
||||||
|
if err != nil {
|
||||||
|
return FinishResult{}, err
|
||||||
|
}
|
||||||
|
codes, recoveryDigests, err := authrecovery.GenerateCodeSet(service.random, service.codeCount)
|
||||||
|
if err != nil {
|
||||||
|
return FinishResult{}, err
|
||||||
|
}
|
||||||
|
completion, err := service.completion(registration, recoveryDigests)
|
||||||
|
if err != nil {
|
||||||
|
return FinishResult{}, err
|
||||||
|
}
|
||||||
|
credential, err := service.passkeys.FinishAccountRegistration(ctx, ceremonyToken, []byte(registrationToken), response, func(commitCtx context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
|
||||||
|
completion.Credential = &verified
|
||||||
|
completion.AuthAudit = passkeyAudit
|
||||||
|
return service.repository.CompleteRegistration(commitCtx, registration.Digest, completion)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return FinishResult{}, err
|
||||||
|
}
|
||||||
|
return service.finishSession(ctx, registration, completion, codes, credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) finishSession(ctx context.Context, registration Registration, completion RegistrationCompletion, codes []string, credential authwebauthn.Credential) (FinishResult, error) {
|
||||||
|
user := registration.User
|
||||||
|
user.RegistrationPending = false
|
||||||
|
user.UpdatedAt = completion.CompletedAt
|
||||||
|
result := FinishResult{User: user, Organization: completion.Organization, RecoveryCodes: codes, PasskeyCredential: credential}
|
||||||
|
sessionToken, principal, err := service.sessions.IssueSession(ctx, user.ID, service.sessionTTL)
|
||||||
|
if err != nil {
|
||||||
|
// Registration is already durable. Preserve the one-time recovery codes
|
||||||
|
// in the returned result so an application can display them while asking
|
||||||
|
// the user to sign in again.
|
||||||
|
return result, fmt.Errorf("account: registration completed but session issuance failed: %w", err)
|
||||||
|
}
|
||||||
|
result.SessionToken, result.Principal = sessionToken, principal
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) completion(registration Registration, recoveryDigests [][32]byte) (RegistrationCompletion, error) {
|
||||||
|
organizationID, err := service.token(18)
|
||||||
|
if err != nil {
|
||||||
|
return RegistrationCompletion{}, err
|
||||||
|
}
|
||||||
|
bindingID, err := service.token(18)
|
||||||
|
if err != nil {
|
||||||
|
return RegistrationCompletion{}, err
|
||||||
|
}
|
||||||
|
slugBytes := make([]byte, 6)
|
||||||
|
if _, err = io.ReadFull(service.random, slugBytes); err != nil {
|
||||||
|
return RegistrationCompletion{}, fmt.Errorf("account: secure randomness unavailable: %w", err)
|
||||||
|
}
|
||||||
|
now := service.now().UTC()
|
||||||
|
organization := organizations.Organization{ID: organizationID, Slug: "personal-" + hex.EncodeToString(slugBytes), Name: registration.User.DisplayName + " — Personal", Status: "active", Personal: true, Revision: 1, CreatedAt: now, UpdatedAt: now}
|
||||||
|
membership := organizations.Membership{OrganizationID: organizationID, UserID: registration.User.ID, Status: "active", JoinedAt: now}
|
||||||
|
binding := access.Binding{ID: bindingID, SubjectKind: access.User, SubjectID: registration.User.ID, Role: service.ownerRole, Scope: access.Scope{OrganizationID: organizationID}, GrantedBy: registration.User.ID, GrantedAt: now}
|
||||||
|
organizationAuditID, err := service.token(18)
|
||||||
|
if err != nil {
|
||||||
|
return RegistrationCompletion{}, err
|
||||||
|
}
|
||||||
|
accessAuditID, err := service.token(18)
|
||||||
|
if err != nil {
|
||||||
|
return RegistrationCompletion{}, err
|
||||||
|
}
|
||||||
|
return RegistrationCompletion{
|
||||||
|
RecoveryDigests: recoveryDigests,
|
||||||
|
Organization: organization,
|
||||||
|
Membership: membership,
|
||||||
|
OwnerBinding: binding,
|
||||||
|
OrganizationAudit: organizations.AuditEvent{ID: organizationAuditID, OrganizationID: organizationID, ActorUserID: registration.User.ID, Action: "organization.personal.create", ResourceType: "organization", ResourceID: organizationID, Summary: "Personal organization created during account registration.", CreatedAt: now},
|
||||||
|
AccessAudit: access.AuditEvent{ID: accessAuditID, OrganizationID: organizationID, ActorUserID: registration.User.ID, Action: "access.owner.grant", ResourceType: "user", ResourceID: registration.User.ID, Summary: "Initial personal-organization owner access granted.", CreatedAt: now},
|
||||||
|
CompletedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) registration(ctx context.Context, raw string) (Registration, error) {
|
||||||
|
if len(raw) < 32 || len(raw) > 128 {
|
||||||
|
return Registration{}, ErrRegistrationNotFound
|
||||||
|
}
|
||||||
|
if _, err := base64.RawURLEncoding.DecodeString(raw); err != nil {
|
||||||
|
return Registration{}, ErrRegistrationNotFound
|
||||||
|
}
|
||||||
|
return service.repository.Registration(ctx, sha256.Sum256([]byte(raw)), service.now().UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) authAudit(userID, action, summary string) (auth.AuditEvent, error) {
|
||||||
|
id, err := service.token(18)
|
||||||
|
if err != nil {
|
||||||
|
return auth.AuditEvent{}, err
|
||||||
|
}
|
||||||
|
return auth.AuditEvent{ID: id, ActorUserID: userID, Action: action, ResourceType: "user", ResourceID: userID, Summary: summary, CreatedAt: service.now().UTC()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) token(size int) (string, error) {
|
||||||
|
value := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(service.random, value); err != nil {
|
||||||
|
return "", fmt.Errorf("account: secure randomness unavailable: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalEmail(value string) (string, error) {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
parsed, err := mail.ParseAddress(value)
|
||||||
|
if err != nil || parsed.Address != value || len(value) > 320 || strings.ContainsAny(value, "\x00\r\n") {
|
||||||
|
return "", errors.New("account: a valid email address is required")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func roleName(value string) bool {
|
||||||
|
if len(value) < 2 || len(value) > 128 || value[0] < 'a' || value[0] > 'z' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value[1:] {
|
||||||
|
if character < 'a' || character > 'z' && (character < '0' || character > '9') && character != '.' && character != '_' && character != '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+35
-17
@@ -31,8 +31,14 @@ type User struct {
|
|||||||
ID, Username, Email, DisplayName, Status string
|
ID, Username, Email, DisplayName, Status string
|
||||||
CreatedAt, UpdatedAt time.Time
|
CreatedAt, UpdatedAt time.Time
|
||||||
PasswordChangeRequired bool
|
PasswordChangeRequired bool
|
||||||
|
// RegistrationPending keeps a partially completed public registration
|
||||||
|
// ineligible for authentication until its credentials, personal scope, and
|
||||||
|
// recovery material have been committed atomically.
|
||||||
|
RegistrationPending bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (user User) Active() bool { return user.Status == "active" && !user.RegistrationPending }
|
||||||
|
|
||||||
type Principal struct {
|
type Principal struct {
|
||||||
User User
|
User User
|
||||||
Roles []string
|
Roles []string
|
||||||
@@ -167,7 +173,7 @@ func (service *Service) ChangePassword(ctx context.Context, userID, currentPassw
|
|||||||
if !VerifyPassword(currentHash, currentPassword) {
|
if !VerifyPassword(currentHash, currentPassword) {
|
||||||
return ErrInvalidCredentials
|
return ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
if user.Status != "active" {
|
if !user.Active() {
|
||||||
return ErrInactiveUser
|
return ErrInactiveUser
|
||||||
}
|
}
|
||||||
if currentPassword == newPassword {
|
if currentPassword == newPassword {
|
||||||
@@ -199,7 +205,7 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err)
|
return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err)
|
||||||
}
|
}
|
||||||
if user.Status != "active" {
|
if !user.Active() {
|
||||||
return User{}, ErrInactiveUser
|
return User{}, ErrInactiveUser
|
||||||
}
|
}
|
||||||
if VerifyPassword(currentHash, input.TemporaryPassword) {
|
if VerifyPassword(currentHash, input.TemporaryPassword) {
|
||||||
@@ -233,24 +239,36 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerifyPassword verifies the password credential for an active account
|
||||||
|
// without creating a session. Applications use it as the first step of a
|
||||||
|
// bounded multi-factor ceremony and must not treat success as an authenticated
|
||||||
|
// browser session on its own.
|
||||||
|
func (service *Service) VerifyPassword(ctx context.Context, identifier, password string) (User, error) {
|
||||||
|
user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier))
|
||||||
|
if errors.Is(err, ErrUserNotFound) {
|
||||||
|
_ = VerifyPassword(dummyPasswordHash, password)
|
||||||
|
return User{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
_ = VerifyPassword(dummyPasswordHash, password)
|
||||||
|
return User{}, fmt.Errorf("auth: load credentials: %w", err)
|
||||||
|
}
|
||||||
|
if !VerifyPassword(hash, password) {
|
||||||
|
return User{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if !user.Active() {
|
||||||
|
return User{}, ErrInactiveUser
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) {
|
func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) {
|
||||||
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
|
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
|
||||||
return "", Principal{}, errors.New("auth: invalid session lifetime")
|
return "", Principal{}, errors.New("auth: invalid session lifetime")
|
||||||
}
|
}
|
||||||
user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier))
|
user, err := service.VerifyPassword(ctx, identifier, password)
|
||||||
if errors.Is(err, ErrUserNotFound) {
|
|
||||||
_ = VerifyPassword(dummyPasswordHash, password)
|
|
||||||
return "", Principal{}, ErrInvalidCredentials
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = VerifyPassword(dummyPasswordHash, password)
|
return "", Principal{}, err
|
||||||
return "", Principal{}, fmt.Errorf("auth: load credentials: %w", err)
|
|
||||||
}
|
|
||||||
if !VerifyPassword(hash, password) {
|
|
||||||
return "", Principal{}, ErrInvalidCredentials
|
|
||||||
}
|
|
||||||
if user.Status != "active" {
|
|
||||||
return "", Principal{}, ErrInactiveUser
|
|
||||||
}
|
}
|
||||||
return service.IssueSession(ctx, user.ID, lifetime)
|
return service.IssueSession(ctx, user.ID, lifetime)
|
||||||
}
|
}
|
||||||
@@ -282,7 +300,7 @@ func (service *Service) IssueSession(ctx context.Context, userID string, lifetim
|
|||||||
_ = service.repository.DeleteSession(ctx, digest)
|
_ = service.repository.DeleteSession(ctx, digest)
|
||||||
return "", Principal{}, err
|
return "", Principal{}, err
|
||||||
}
|
}
|
||||||
if principal.User.Status != "active" {
|
if !principal.User.Active() {
|
||||||
_ = service.repository.DeleteSession(ctx, digest)
|
_ = service.repository.DeleteSession(ctx, digest)
|
||||||
return "", Principal{}, ErrInactiveUser
|
return "", Principal{}, ErrInactiveUser
|
||||||
}
|
}
|
||||||
@@ -302,7 +320,7 @@ func (service *Service) Session(ctx context.Context, token string) (Principal, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Principal{}, fmt.Errorf("auth: load session: %w", err)
|
return Principal{}, fmt.Errorf("auth: load session: %w", err)
|
||||||
}
|
}
|
||||||
if principal.User.Status != "active" {
|
if !principal.User.Active() {
|
||||||
_ = service.repository.DeleteSession(ctx, digest)
|
_ = service.repository.DeleteSession(ctx, digest)
|
||||||
return Principal{}, ErrInactiveUser
|
return Principal{}, ErrInactiveUser
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -57,6 +57,31 @@ func TestIssueSessionRejectsInactiveRepositoryPrincipal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVerifyPasswordDoesNotIssueSession(t *testing.T) {
|
||||||
|
hash, err := HashPassword("correct horse battery staple")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repository := &credentialRepository{
|
||||||
|
user: User{ID: "valid-user-id", Username: "person", Email: "person@example.test", Status: "active"},
|
||||||
|
hash: hash,
|
||||||
|
}
|
||||||
|
service, err := New(repository, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user, err := service.VerifyPassword(t.Context(), "person@example.test", "correct horse battery staple")
|
||||||
|
if err != nil || user.ID != repository.user.ID {
|
||||||
|
t.Fatalf("user=%+v err=%v", user, err)
|
||||||
|
}
|
||||||
|
if repository.sessionCreated {
|
||||||
|
t.Fatal("password verification issued a session")
|
||||||
|
}
|
||||||
|
if _, err = service.VerifyPassword(t.Context(), "person@example.test", "wrong password"); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("wrong password err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type recordingRepository struct {
|
type recordingRepository struct {
|
||||||
repositoryStub
|
repositoryStub
|
||||||
deleted bool
|
deleted bool
|
||||||
@@ -68,6 +93,22 @@ type activeSessionRepository struct {
|
|||||||
deleted bool
|
deleted bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type credentialRepository struct {
|
||||||
|
repositoryStub
|
||||||
|
user User
|
||||||
|
hash string
|
||||||
|
sessionCreated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repository *credentialRepository) CredentialByIdentifier(context.Context, string) (User, string, error) {
|
||||||
|
return repository.user, repository.hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repository *credentialRepository) CreateSession(context.Context, Session) error {
|
||||||
|
repository.sessionCreated = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (repository *activeSessionRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
|
func (repository *activeSessionRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
|
||||||
return repository.principal, Session{}, nil
|
return repository.principal, Session{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authrecovery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/authrecovery"
|
||||||
|
"gamertan.com/web/authsqlite"
|
||||||
|
"gamertan.com/web/authwebauthn"
|
||||||
|
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
|
||||||
|
"gamertan.com/web/organizations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOwnerAssistedRecoveryInvalidatesAndAtomicallyReplacesAccountCredentials(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
store, err := authsqlite.Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
random := &counterReader{}
|
||||||
|
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "home.owner", Email: "owner@example.test", DisplayName: "Home Owner", Password: "owner password for assisted recovery"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.member", Email: "member@example.test", DisplayName: "Recover Member", Password: "old member password before recovery"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
organizationsService, err := organizations.New(store, organizations.Options{Random: random, Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
home, err := organizationsService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "assisted-home", Name: "Assisted Home", OwnerUserID: owner.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
invitation, _, err := organizationsService.Invite(t.Context(), home.ID, target.Email, owner.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = organizationsService.AcceptInvitation(t.Context(), invitation, target.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
policy := access.Policy{
|
||||||
|
Roles: map[string]string{"owner": "Organization owner", "viewer": "Organization viewer"},
|
||||||
|
Permissions: map[string]string{"account.recover": "Recover an organization member"},
|
||||||
|
Grants: map[string][]string{"owner": {"account.recover"}, "viewer": {}},
|
||||||
|
}
|
||||||
|
accessService, err := access.New(store, policy, access.Options{Random: random, 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)
|
||||||
|
}
|
||||||
|
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: target.ID, Role: "viewer", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingID := bytes.Repeat([]byte{7}, 32)
|
||||||
|
existingJSON, err := json.Marshal(wa.Credential{ID: existingID, PublicKey: []byte{1, 2, 3}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: existingID, UserID: target.ID, Label: "Old passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "old-passkey-audit-id", ActorUserID: target.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Old passkey fixture", CreatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
passkeys := &passkeyRecoveryStub{now: now, credentialID: bytes.Repeat([]byte{8}, 32)}
|
||||||
|
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys, OwnerRole: "owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldCodes, err := recovery.ReplaceCodes(t.Context(), target.ID, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldSession, _, err := authService.IssueSession(t.Context(), target.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err = recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: target.ID, TargetUserID: owner.ID, RequestID: "request-denied-123", Reason: "Target asked for recovery after identity review"}); !errors.Is(err, authrecovery.ErrAssistedDenied) {
|
||||||
|
t.Fatalf("non-owner assisted recovery err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); err != nil {
|
||||||
|
t.Fatalf("denied recovery changed password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, grant, err := recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: owner.ID, TargetUserID: target.ID, RequestID: "request-assisted-123", Reason: "Member verified ownership through the documented support review"})
|
||||||
|
if err != nil || loaded.ID != target.ID || grant == "" {
|
||||||
|
t.Fatalf("loaded=%+v grant_present=%v err=%v", loaded, grant != "", err)
|
||||||
|
}
|
||||||
|
if _, err = authService.Session(t.Context(), oldSession); !errors.Is(err, auth.ErrSessionNotFound) {
|
||||||
|
t.Fatalf("old session survived assisted recovery issue: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("old password survived assisted recovery issue: %v", err)
|
||||||
|
}
|
||||||
|
credentials, err := store.CredentialsByUserID(t.Context(), target.ID)
|
||||||
|
if err != nil || len(credentials) != 0 {
|
||||||
|
t.Fatalf("old passkeys survived issue: credentials=%+v err=%v", credentials, err)
|
||||||
|
}
|
||||||
|
if _, _, err = recovery.Begin(t.Context(), target.Email, "old member password before recovery", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("old recovery path survived issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
begin, err := recovery.BeginAssistedPasskey(t.Context(), grant, "Recovered passkey")
|
||||||
|
if err != nil || begin.CeremonyToken == "" || passkeys.userID != target.ID || passkeys.beginBinding != grant {
|
||||||
|
t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err)
|
||||||
|
}
|
||||||
|
result, err := recovery.FinishAssistedRecovery(t.Context(), grant, begin.CeremonyToken, "new member password after recovery", []byte(`{"fixture":true}`))
|
||||||
|
if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount {
|
||||||
|
t.Fatalf("result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
if _, err = authService.VerifyPassword(t.Context(), target.Email, "new member password after recovery"); err != nil {
|
||||||
|
t.Fatalf("replacement password unavailable: %v", err)
|
||||||
|
}
|
||||||
|
credentials, err = store.CredentialsByUserID(t.Context(), target.ID)
|
||||||
|
if err != nil || len(credentials) != 1 || !bytes.Equal(credentials[0].ID, passkeys.credentialID) {
|
||||||
|
t.Fatalf("replacement credentials=%+v err=%v", credentials, err)
|
||||||
|
}
|
||||||
|
if _, err = recovery.BeginAssistedPasskey(t.Context(), grant, "Replay"); !errors.Is(err, authrecovery.ErrAssistedNotFound) {
|
||||||
|
t.Fatalf("assisted grant replay err=%v", err)
|
||||||
|
}
|
||||||
|
if _, nextGrant, beginErr := recovery.Begin(t.Context(), target.Email, "new member password after recovery", result.RecoveryCodes[0]); beginErr != nil || nextGrant == "" {
|
||||||
|
t.Fatalf("replacement recovery material unavailable: grant_present=%v err=%v", nextGrant != "", beginErr)
|
||||||
|
}
|
||||||
|
audits, err := accessService.Audit(t.Context(), home.ID, 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seenIssue, seenComplete := false, false
|
||||||
|
for _, audit := range audits {
|
||||||
|
seenIssue = seenIssue || audit.Action == "access.account-recovery.issue" && audit.ActorUserID == owner.ID && audit.ResourceID == target.ID && audit.RequestID == "request-assisted-123"
|
||||||
|
seenComplete = seenComplete || audit.Action == "access.account-recovery.complete" && audit.ActorUserID == target.ID && audit.ResourceID == target.ID
|
||||||
|
}
|
||||||
|
if !seenIssue || !seenComplete {
|
||||||
|
t.Fatalf("organization recovery audits issue=%v complete=%v events=%+v", seenIssue, seenComplete, audits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Package authrecovery provides printable one-time recovery codes and bounded
|
||||||
|
// recovery grants for password-plus-passkey accounts.
|
||||||
|
package authrecovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/authwebauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultCodeCount = 10
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
|
||||||
|
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
|
||||||
|
ErrAssistedNotFound = errors.New("authrecovery: assisted recovery grant not found")
|
||||||
|
ErrAssistedDenied = errors.New("authrecovery: assisted recovery is not authorized")
|
||||||
|
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Grant struct {
|
||||||
|
Digest [32]byte
|
||||||
|
UserID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
ReplaceRecoveryCodes(context.Context, string, [][32]byte, time.Time, auth.AuditEvent) error
|
||||||
|
ConsumeRecoveryCodeAndCreateGrant(context.Context, string, [32]byte, Grant, auth.AuditEvent) error
|
||||||
|
TakeRecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyRepository adds the transactional boundary required to finish a
|
||||||
|
// password-plus-recovery-code flow without issuing a normal session.
|
||||||
|
type PasskeyRepository interface {
|
||||||
|
Repository
|
||||||
|
RecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error)
|
||||||
|
CompletePasskeyRecovery(context.Context, PasskeyCompletion) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssistedGrant is the digest-only authority created by an organization
|
||||||
|
// owner after a human recovery review. The plaintext token is returned once
|
||||||
|
// to the caller and never persisted or audited.
|
||||||
|
type AssistedGrant struct {
|
||||||
|
Digest [32]byte
|
||||||
|
OrganizationID, UserID string
|
||||||
|
IssuedByUserID string
|
||||||
|
CreatedAt, ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssistedIssue binds an owner-reviewed recovery to one organization member.
|
||||||
|
// Reason is deliberately bounded and must not contain credential material.
|
||||||
|
type AssistedIssue struct {
|
||||||
|
OrganizationID, ActorUserID, TargetUserID, RequestID, Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssistedRepository provides the two transactional boundaries for delegated
|
||||||
|
// recovery. Issuance invalidates all existing account authenticators and
|
||||||
|
// sessions while recording both identity and organization-visible audits.
|
||||||
|
// Completion consumes the grant exactly once and installs the replacement
|
||||||
|
// password, passkey, and recovery-code set atomically.
|
||||||
|
type AssistedRepository interface {
|
||||||
|
Repository
|
||||||
|
IssueAssistedRecovery(context.Context, AssistedGrant, string, auth.AuditEvent, access.AuditEvent) (auth.User, error)
|
||||||
|
AssistedRecoveryGrant(context.Context, [32]byte, time.Time) (AssistedGrant, auth.User, error)
|
||||||
|
CompleteAssistedRecovery(context.Context, AssistedCompletion) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssistedCompletion contains only the password hash, public passkey
|
||||||
|
// credential, digest-only recovery codes, and secret-free audit material.
|
||||||
|
type AssistedCompletion struct {
|
||||||
|
GrantDigest [32]byte
|
||||||
|
Credential authwebauthn.Credential
|
||||||
|
PasswordHash string
|
||||||
|
RecoveryDigests [][32]byte
|
||||||
|
PasskeyAudit auth.AuditEvent
|
||||||
|
RecoveryAudit auth.AuditEvent
|
||||||
|
AccessAudit access.AuditEvent
|
||||||
|
CompletedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passkeys performs recovery-bound WebAuthn registration ceremonies.
|
||||||
|
type Passkeys interface {
|
||||||
|
BeginRecoveryRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
|
||||||
|
FinishRecoveryRegistration(context.Context, string, []byte, []byte, authwebauthn.RegistrationCommit) (authwebauthn.Credential, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyCompletion contains the public credential, digest-only replacement
|
||||||
|
// codes, and secret-free audits committed after a recovery ceremony.
|
||||||
|
type PasskeyCompletion struct {
|
||||||
|
GrantDigest [32]byte
|
||||||
|
Credential authwebauthn.Credential
|
||||||
|
RecoveryDigests [][32]byte
|
||||||
|
PasskeyAudit auth.AuditEvent
|
||||||
|
RecoveryAudit auth.AuditEvent
|
||||||
|
CompletedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyFinishResult returns the verified credential and the new plaintext
|
||||||
|
// recovery codes. Applications must display the codes once and retain none.
|
||||||
|
type PasskeyFinishResult struct {
|
||||||
|
Credential authwebauthn.Credential
|
||||||
|
RecoveryCodes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordVerifier interface {
|
||||||
|
VerifyPassword(context.Context, string, string) (auth.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Random io.Reader
|
||||||
|
Now func() time.Time
|
||||||
|
CodeCount int
|
||||||
|
GrantLifetime time.Duration
|
||||||
|
AssistedGrantLifetime time.Duration
|
||||||
|
OwnerRole string
|
||||||
|
Passkeys Passkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repository Repository
|
||||||
|
passwords PasswordVerifier
|
||||||
|
random io.Reader
|
||||||
|
now func() time.Time
|
||||||
|
count int
|
||||||
|
grantTTL time.Duration
|
||||||
|
assistedTTL time.Duration
|
||||||
|
ownerRole string
|
||||||
|
passkeys Passkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
|
||||||
|
if repository == nil || passwords == nil {
|
||||||
|
return nil, errors.New("authrecovery: repository and password verifier are required")
|
||||||
|
}
|
||||||
|
if options.Random == nil {
|
||||||
|
options.Random = rand.Reader
|
||||||
|
}
|
||||||
|
if options.Now == nil {
|
||||||
|
options.Now = time.Now
|
||||||
|
}
|
||||||
|
if options.CodeCount == 0 {
|
||||||
|
options.CodeCount = DefaultCodeCount
|
||||||
|
}
|
||||||
|
if options.GrantLifetime == 0 {
|
||||||
|
options.GrantLifetime = 10 * time.Minute
|
||||||
|
}
|
||||||
|
if options.AssistedGrantLifetime == 0 {
|
||||||
|
options.AssistedGrantLifetime = 15 * time.Minute
|
||||||
|
}
|
||||||
|
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute || options.AssistedGrantLifetime < 5*time.Minute || options.AssistedGrantLifetime > 30*time.Minute || options.OwnerRole != "" && !safeRole(options.OwnerRole) {
|
||||||
|
return nil, errors.New("authrecovery: invalid recovery policy")
|
||||||
|
}
|
||||||
|
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, assistedTTL: options.AssistedGrantLifetime, ownerRole: options.OwnerRole, passkeys: options.Passkeys}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueAssistedRecovery creates one owner-authorized, single-use recovery
|
||||||
|
// token. The repository immediately invalidates the target's previous
|
||||||
|
// password, passkeys, recovery codes, sessions, and pending ceremonies so the
|
||||||
|
// reviewed recovery cannot race an older authenticator.
|
||||||
|
func (service *Service) IssueAssistedRecovery(ctx context.Context, input AssistedIssue) (auth.User, string, error) {
|
||||||
|
repository, ok := service.repository.(AssistedRepository)
|
||||||
|
input.OrganizationID = strings.TrimSpace(input.OrganizationID)
|
||||||
|
input.ActorUserID = strings.TrimSpace(input.ActorUserID)
|
||||||
|
input.TargetUserID = strings.TrimSpace(input.TargetUserID)
|
||||||
|
input.RequestID = strings.TrimSpace(input.RequestID)
|
||||||
|
input.Reason = strings.TrimSpace(input.Reason)
|
||||||
|
if !ok || service.passkeys == nil || service.ownerRole == "" {
|
||||||
|
return auth.User{}, "", ErrPasskeyUnavailable
|
||||||
|
}
|
||||||
|
if !opaqueID(input.OrganizationID) || !opaqueID(input.ActorUserID) || !opaqueID(input.TargetUserID) || input.RequestID != "" && !opaqueID(input.RequestID) || len(input.Reason) < 8 || len(input.Reason) > 240 || strings.ContainsAny(input.Reason, "\x00\r\n") {
|
||||||
|
return auth.User{}, "", errors.New("authrecovery: invalid assisted recovery request")
|
||||||
|
}
|
||||||
|
raw, err := token(service.random, 32)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, "", err
|
||||||
|
}
|
||||||
|
now := service.now().UTC()
|
||||||
|
grant := AssistedGrant{Digest: sha256.Sum256([]byte(raw)), OrganizationID: input.OrganizationID, UserID: input.TargetUserID, IssuedByUserID: input.ActorUserID, CreatedAt: now, ExpiresAt: now.Add(service.assistedTTL)}
|
||||||
|
authAuditID, err := token(service.random, 18)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, "", err
|
||||||
|
}
|
||||||
|
accessAuditID, err := token(service.random, 18)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, "", err
|
||||||
|
}
|
||||||
|
summary := "Owner-assisted account recovery issued after human review. Reason: " + input.Reason
|
||||||
|
authAudit := auth.AuditEvent{ID: authAuditID, ActorUserID: input.ActorUserID, Action: "auth.assisted-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
|
||||||
|
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.account-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
|
||||||
|
user, err := repository.IssueAssistedRecovery(ctx, grant, service.ownerRole, authAudit, accessAudit)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, "", err
|
||||||
|
}
|
||||||
|
return user, raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginAssistedPasskey starts a replacement ceremony without issuing a normal
|
||||||
|
// session. The grant remains reusable for ceremony restart until completion or
|
||||||
|
// expiry; only completion consumes it.
|
||||||
|
func (service *Service) BeginAssistedPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) {
|
||||||
|
repository, ok := service.repository.(AssistedRepository)
|
||||||
|
if !ok || service.passkeys == nil {
|
||||||
|
return authwebauthn.BeginResult{}, ErrPasskeyUnavailable
|
||||||
|
}
|
||||||
|
digest, err := grantDigest(rawGrant)
|
||||||
|
if err != nil {
|
||||||
|
return authwebauthn.BeginResult{}, ErrAssistedNotFound
|
||||||
|
}
|
||||||
|
_, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return authwebauthn.BeginResult{}, err
|
||||||
|
}
|
||||||
|
return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishAssistedRecovery consumes a reviewed grant only inside the transaction
|
||||||
|
// that installs every replacement credential and both audit trails. No normal
|
||||||
|
// session is issued; the recovered user signs in with the new credentials.
|
||||||
|
func (service *Service) FinishAssistedRecovery(ctx context.Context, rawGrant, ceremonyToken, password string, response []byte) (PasskeyFinishResult, error) {
|
||||||
|
repository, ok := service.repository.(AssistedRepository)
|
||||||
|
if !ok || service.passkeys == nil {
|
||||||
|
return PasskeyFinishResult{}, ErrPasskeyUnavailable
|
||||||
|
}
|
||||||
|
digest, err := grantDigest(rawGrant)
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, ErrAssistedNotFound
|
||||||
|
}
|
||||||
|
grant, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
passwordHash, err := auth.HashPasswordWithRandom(password, service.random)
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
codes, digests, err := GenerateCodeSet(service.random, service.count)
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
credential, err := service.passkeys.FinishRecoveryRegistration(ctx, ceremonyToken, []byte(rawGrant), response, func(commitContext context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
|
||||||
|
if verified.UserID != user.ID {
|
||||||
|
return errors.New("authrecovery: assisted recovery identity mismatch")
|
||||||
|
}
|
||||||
|
completedAt := service.now().UTC()
|
||||||
|
recoveryAuditID, auditErr := token(service.random, 18)
|
||||||
|
if auditErr != nil {
|
||||||
|
return auditErr
|
||||||
|
}
|
||||||
|
accessAuditID, auditErr := token(service.random, 18)
|
||||||
|
if auditErr != nil {
|
||||||
|
return auditErr
|
||||||
|
}
|
||||||
|
recoveryAudit := auth.AuditEvent{ID: recoveryAuditID, ActorUserID: user.ID, Action: "auth.assisted-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Owner-assisted recovery replaced the password, passkeys, recovery codes, and sessions.", CreatedAt: completedAt}
|
||||||
|
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: grant.OrganizationID, ActorUserID: user.ID, Action: "access.account-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "The organization member completed owner-assisted account recovery.", CreatedAt: completedAt}
|
||||||
|
return repository.CompleteAssistedRecovery(commitContext, AssistedCompletion{GrantDigest: digest, Credential: verified, PasswordHash: passwordHash, RecoveryDigests: digests, PasskeyAudit: passkeyAudit, RecoveryAudit: recoveryAudit, AccessAudit: accessAudit, CompletedAt: completedAt})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
|
||||||
|
// once; only domain-separated digests are persisted.
|
||||||
|
func (service *Service) ReplaceCodes(ctx context.Context, userID, actorUserID string) ([]string, error) {
|
||||||
|
codes, digests, err := GenerateCodeSet(service.random, service.count)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := service.now().UTC()
|
||||||
|
auditID, err := token(service.random, 18)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
audit := auth.AuditEvent{ID: auditID, ActorUserID: actorUserID, Action: "auth.recovery-codes.replace", ResourceType: "user", ResourceID: userID, Summary: "The account recovery-code set was replaced.", CreatedAt: now}
|
||||||
|
if err = service.repository.ReplaceRecoveryCodes(ctx, userID, digests, now, audit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin verifies the password, atomically consumes one code, revokes sessions,
|
||||||
|
// and returns a short-lived grant. Applications bind the grant to the passkey
|
||||||
|
// replacement ceremony and do not issue a normal session from it.
|
||||||
|
func (service *Service) Begin(ctx context.Context, identifier, password, code string) (auth.User, string, error) {
|
||||||
|
user, err := service.passwords.VerifyPassword(ctx, identifier, password)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, "", err
|
||||||
|
}
|
||||||
|
digest, err := DigestCode(code)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, "", auth.ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
rawGrant, err := token(service.random, 32)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, "", err
|
||||||
|
}
|
||||||
|
now := service.now().UTC()
|
||||||
|
grant := Grant{Digest: sha256.Sum256([]byte(rawGrant)), UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(service.grantTTL)}
|
||||||
|
auditID, err := token(service.random, 18)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, "", err
|
||||||
|
}
|
||||||
|
audit := auth.AuditEvent{ID: auditID, ActorUserID: user.ID, Action: "auth.recovery.begin", ResourceType: "user", ResourceID: user.ID, Summary: "A recovery code was consumed and existing sessions were revoked.", CreatedAt: now}
|
||||||
|
if err = service.repository.ConsumeRecoveryCodeAndCreateGrant(ctx, user.ID, digest, grant, audit); err != nil {
|
||||||
|
if errors.Is(err, ErrCodeNotFound) {
|
||||||
|
return auth.User{}, "", auth.ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return auth.User{}, "", err
|
||||||
|
}
|
||||||
|
return user, rawGrant, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) TakeGrant(ctx context.Context, raw string) (auth.User, error) {
|
||||||
|
digest, err := grantDigest(raw)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
return service.repository.TakeRecoveryGrant(ctx, digest, service.now().UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginPasskey starts a ceremony only for a live restricted recovery grant.
|
||||||
|
// The raw grant remains application-held so a failed or interrupted ceremony
|
||||||
|
// can be restarted until the grant expires.
|
||||||
|
func (service *Service) BeginPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) {
|
||||||
|
repository, ok := service.repository.(PasskeyRepository)
|
||||||
|
if !ok || service.passkeys == nil {
|
||||||
|
return authwebauthn.BeginResult{}, ErrPasskeyUnavailable
|
||||||
|
}
|
||||||
|
digest, err := grantDigest(rawGrant)
|
||||||
|
if err != nil {
|
||||||
|
return authwebauthn.BeginResult{}, err
|
||||||
|
}
|
||||||
|
user, err := repository.RecoveryGrant(ctx, digest, service.now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return authwebauthn.BeginResult{}, err
|
||||||
|
}
|
||||||
|
return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishPasskey consumes the grant only inside the transaction that stores the
|
||||||
|
// verified passkey and a fresh recovery-code set. It never issues a session.
|
||||||
|
func (service *Service) FinishPasskey(ctx context.Context, rawGrant, ceremonyToken string, response []byte) (PasskeyFinishResult, error) {
|
||||||
|
repository, ok := service.repository.(PasskeyRepository)
|
||||||
|
if !ok || service.passkeys == nil {
|
||||||
|
return PasskeyFinishResult{}, ErrPasskeyUnavailable
|
||||||
|
}
|
||||||
|
digest, err := grantDigest(rawGrant)
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
user, err := repository.RecoveryGrant(ctx, digest, service.now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
codes, digests, err := GenerateCodeSet(service.random, service.count)
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
credential, err := service.passkeys.FinishRecoveryRegistration(ctx, ceremonyToken, []byte(rawGrant), response, func(commitContext context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
|
||||||
|
if verified.UserID != user.ID {
|
||||||
|
return errors.New("authrecovery: recovery identity mismatch")
|
||||||
|
}
|
||||||
|
completedAt := service.now().UTC()
|
||||||
|
auditID, auditErr := token(service.random, 18)
|
||||||
|
if auditErr != nil {
|
||||||
|
return auditErr
|
||||||
|
}
|
||||||
|
recoveryAudit := auth.AuditEvent{ID: auditID, ActorUserID: user.ID, Action: "auth.recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Account recovery enrolled a replacement passkey and replaced the recovery-code set.", CreatedAt: completedAt}
|
||||||
|
return repository.CompletePasskeyRecovery(commitContext, PasskeyCompletion{
|
||||||
|
GrantDigest: digest,
|
||||||
|
Credential: verified,
|
||||||
|
RecoveryDigests: digests,
|
||||||
|
PasskeyAudit: passkeyAudit,
|
||||||
|
RecoveryAudit: recoveryAudit,
|
||||||
|
CompletedAt: completedAt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateCodeSet(random io.Reader, count int) ([]string, [][32]byte, error) {
|
||||||
|
if random == nil || count < 1 || count > 20 {
|
||||||
|
return nil, nil, errors.New("authrecovery: invalid code-set request")
|
||||||
|
}
|
||||||
|
codes := make([]string, 0, count)
|
||||||
|
digests := make([][32]byte, 0, count)
|
||||||
|
seen := make(map[[32]byte]struct{}, count)
|
||||||
|
for len(codes) < count {
|
||||||
|
value := make([]byte, 16)
|
||||||
|
if _, err := io.ReadFull(random, value); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("authrecovery: secure randomness unavailable: %w", err)
|
||||||
|
}
|
||||||
|
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(value)
|
||||||
|
code := strings.Join([]string{encoded[0:5], encoded[5:10], encoded[10:15], encoded[15:20], encoded[20:26]}, "-")
|
||||||
|
digest, _ := DigestCode(code)
|
||||||
|
if _, duplicate := seen[digest]; duplicate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[digest] = struct{}{}
|
||||||
|
codes = append(codes, code)
|
||||||
|
digests = append(digests, digest)
|
||||||
|
}
|
||||||
|
return codes, digests, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DigestCode(code string) ([32]byte, error) {
|
||||||
|
normalized := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(code), "-", ""), " ", ""))
|
||||||
|
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalized)
|
||||||
|
if err != nil || len(decoded) != 16 {
|
||||||
|
return [32]byte{}, ErrCodeNotFound
|
||||||
|
}
|
||||||
|
return sha256.Sum256(append([]byte("gamertan-web-recovery-code-v1\x00"), decoded...)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantDigest(raw string) ([32]byte, error) {
|
||||||
|
if len(raw) < 32 || len(raw) > 128 {
|
||||||
|
return [32]byte{}, ErrGrantNotFound
|
||||||
|
}
|
||||||
|
if _, err := base64.RawURLEncoding.DecodeString(raw); err != nil {
|
||||||
|
return [32]byte{}, ErrGrantNotFound
|
||||||
|
}
|
||||||
|
return sha256.Sum256([]byte(raw)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func token(random io.Reader, size int) (string, error) {
|
||||||
|
value := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(random, value); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func opaqueID(value string) bool {
|
||||||
|
if len(value) < 8 || len(value) > 128 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if character == '-' || character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeRole(value string) bool {
|
||||||
|
if len(value) < 1 || len(value) > 96 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if character == '-' || character == '_' || character == '.' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authrecovery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/authrecovery"
|
||||||
|
"gamertan.com/web/authsqlite"
|
||||||
|
"gamertan.com/web/authwebauthn"
|
||||||
|
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
store, err := authsqlite.Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
random := &counterReader{}
|
||||||
|
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.person", Email: "recover@example.test", DisplayName: "Recover Person", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
codes, err := recovery.ReplaceCodes(t.Context(), user.ID, user.ID)
|
||||||
|
if err != nil || len(codes) != authrecovery.DefaultCodeCount {
|
||||||
|
t.Fatalf("codes=%d err=%v", len(codes), err)
|
||||||
|
}
|
||||||
|
session, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, grant, err := recovery.Begin(t.Context(), strings.ToUpper(user.Email), "correct horse battery staple", strings.ToLower(codes[0]))
|
||||||
|
if err != nil || loaded.ID != user.ID || grant == "" {
|
||||||
|
t.Fatalf("loaded=%+v grant=%q err=%v", loaded, grant, err)
|
||||||
|
}
|
||||||
|
if _, err = authService.Session(t.Context(), session); !errors.Is(err, auth.ErrSessionNotFound) {
|
||||||
|
t.Fatalf("session survived recovery: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err = recovery.Begin(t.Context(), user.Email, "correct horse battery staple", codes[0]); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("code replay err=%v", err)
|
||||||
|
}
|
||||||
|
loaded, err = recovery.TakeGrant(t.Context(), grant)
|
||||||
|
if err != nil || loaded.ID != user.ID {
|
||||||
|
t.Fatalf("grant user=%+v err=%v", loaded, err)
|
||||||
|
}
|
||||||
|
if _, err = recovery.TakeGrant(t.Context(), grant); !errors.Is(err, authrecovery.ErrGrantNotFound) {
|
||||||
|
t.Fatalf("grant replay err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyRecoveryAtomicallyReplacesCodesWithoutIssuingSession(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 3, 13, 0, 0, 0, time.UTC)
|
||||||
|
store, err := authsqlite.Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
random := &counterReader{}
|
||||||
|
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.passkey", Email: "recover-passkey@example.test", DisplayName: "Recover Passkey", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
existingID := bytes.Repeat([]byte{7}, 32)
|
||||||
|
existingJSON, err := json.Marshal(wa.Credential{ID: existingID, PublicKey: []byte{1, 2, 3}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: existingID, UserID: user.ID, Label: "Existing passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "existing-passkey-audit", ActorUserID: user.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Existing passkey fixture.", CreatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
passkeys := &passkeyRecoveryStub{now: now, credentialID: existingID}
|
||||||
|
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldCodes, err := recovery.ReplaceCodes(t.Context(), user.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, grant, err := recovery.Begin(t.Context(), user.Email, "correct horse battery staple", oldCodes[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
begin, err := recovery.BeginPasskey(t.Context(), grant, "Replacement passkey")
|
||||||
|
if err != nil || begin.CeremonyToken == "" || passkeys.userID != user.ID || passkeys.beginBinding != grant {
|
||||||
|
t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err)
|
||||||
|
}
|
||||||
|
if _, err = recovery.FinishPasskey(t.Context(), grant, begin.CeremonyToken, []byte(`{"fixture":true}`)); err == nil {
|
||||||
|
t.Fatal("duplicate credential unexpectedly committed")
|
||||||
|
}
|
||||||
|
if _, err = recovery.BeginPasskey(t.Context(), grant, "Retry replacement"); err != nil {
|
||||||
|
t.Fatalf("failed completion consumed recovery grant: %v", err)
|
||||||
|
}
|
||||||
|
lateSession, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
passkeys.credentialID = bytes.Repeat([]byte{8}, 32)
|
||||||
|
result, err := recovery.FinishPasskey(t.Context(), grant, "retry-ceremony-token", []byte(`{"fixture":true}`))
|
||||||
|
if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount || !bytes.Equal(result.Credential.ID, passkeys.credentialID) {
|
||||||
|
t.Fatalf("result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
if passkeys.finishBinding != grant {
|
||||||
|
t.Fatal("finish ceremony was not bound to the restricted recovery grant")
|
||||||
|
}
|
||||||
|
if _, err = recovery.TakeGrant(t.Context(), grant); !errors.Is(err, authrecovery.ErrGrantNotFound) {
|
||||||
|
t.Fatalf("completed grant replay err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = authService.Session(t.Context(), lateSession); !errors.Is(err, auth.ErrSessionNotFound) {
|
||||||
|
t.Fatalf("session created during recovery survived completion: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err = recovery.Begin(t.Context(), user.Email, "correct horse battery staple", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("old recovery-code set survived completion: %v", err)
|
||||||
|
}
|
||||||
|
if _, newGrant, beginErr := recovery.Begin(t.Context(), user.Email, "correct horse battery staple", result.RecoveryCodes[0]); beginErr != nil || newGrant == "" {
|
||||||
|
t.Fatalf("new recovery code unavailable: grant=%q err=%v", newGrant, beginErr)
|
||||||
|
}
|
||||||
|
credentials, err := store.CredentialsByUserID(t.Context(), user.ID)
|
||||||
|
if err != nil || len(credentials) != 2 {
|
||||||
|
t.Fatalf("credentials=%+v err=%v", credentials, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type passkeyRecoveryStub struct {
|
||||||
|
now time.Time
|
||||||
|
userID string
|
||||||
|
credentialID []byte
|
||||||
|
beginBinding string
|
||||||
|
finishBinding string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stub *passkeyRecoveryStub) BeginRecoveryRegistration(_ context.Context, userID, _ string, binding []byte) (authwebauthn.BeginResult, error) {
|
||||||
|
stub.userID = userID
|
||||||
|
stub.beginBinding = string(binding)
|
||||||
|
return authwebauthn.BeginResult{CeremonyToken: "recovery-ceremony-token", PublicKey: json.RawMessage(`{"challenge":"fixture"}`), ExpiresAt: stub.now.Add(5 * time.Minute)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stub *passkeyRecoveryStub) FinishRecoveryRegistration(ctx context.Context, _ string, binding, _ []byte, commit authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) {
|
||||||
|
stub.finishBinding = string(binding)
|
||||||
|
encoded, err := json.Marshal(wa.Credential{ID: stub.credentialID, PublicKey: []byte{1, 2, 3}})
|
||||||
|
if err != nil {
|
||||||
|
return authwebauthn.Credential{}, err
|
||||||
|
}
|
||||||
|
credential := authwebauthn.Credential{ID: append([]byte(nil), stub.credentialID...), UserID: stub.userID, Label: "Replacement passkey", Data: encoded, CreatedAt: stub.now}
|
||||||
|
audit := auth.AuditEvent{ID: "recovery-passkey-audit", ActorUserID: stub.userID, Action: "auth.recovery.passkey", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(stub.credentialID), Summary: "A replacement passkey was enrolled during account recovery.", CreatedAt: stub.now}
|
||||||
|
if err = commit(ctx, credential, audit); err != nil {
|
||||||
|
return authwebauthn.Credential{}, err
|
||||||
|
}
|
||||||
|
return credential, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type counterReader struct{ value byte }
|
||||||
|
|
||||||
|
func (reader *counterReader) Read(target []byte) (int, error) {
|
||||||
|
for index := range target {
|
||||||
|
reader.value++
|
||||||
|
target[index] = reader.value
|
||||||
|
}
|
||||||
|
return len(target), nil
|
||||||
|
}
|
||||||
+198
-5
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gamertan.com/web/access"
|
"gamertan.com/web/access"
|
||||||
@@ -53,9 +54,9 @@ func (store *Store) Grant(ctx context.Context, binding access.Binding) error {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
var exists int
|
var exists int
|
||||||
query := `SELECT COUNT(*) FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status='active'`
|
query := `SELECT COUNT(*) FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active' WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`
|
||||||
if binding.SubjectKind == access.Team {
|
if binding.SubjectKind == access.Team {
|
||||||
query = `SELECT COUNT(*) FROM gwf_teams WHERE organization_id=? AND id=?`
|
query = `SELECT COUNT(*) FROM gwf_teams t JOIN gwf_organizations o ON o.id=t.organization_id AND o.status='active' WHERE t.organization_id=? AND t.id=? AND t.status='active'`
|
||||||
}
|
}
|
||||||
if err = tx.QueryRowContext(ctx, query, binding.Scope.OrganizationID, binding.SubjectID).Scan(&exists); err != nil {
|
if err = tx.QueryRowContext(ctx, query, binding.Scope.OrganizationID, binding.SubjectID).Scan(&exists); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -63,7 +64,7 @@ func (store *Store) Grant(ctx context.Context, binding access.Binding) error {
|
|||||||
if exists != 1 {
|
if exists != 1 {
|
||||||
return errors.New("authsqlite: access subject is not active in organization")
|
return errors.New("authsqlite: access subject is not active in organization")
|
||||||
}
|
}
|
||||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status='active'`, binding.Scope.OrganizationID, binding.GrantedBy).Scan(&exists); err != nil || exists != 1 {
|
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' WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, binding.Scope.OrganizationID, binding.GrantedBy).Scan(&exists); err != nil || exists != 1 {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -110,9 +111,10 @@ func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userI
|
|||||||
}
|
}
|
||||||
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_kind,b.subject_id,b.role_name,b.project_id,b.environment_id,b.service_id,b.granted_by_user_id,b.granted_at
|
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_kind,b.subject_id,b.role_name,b.project_id,b.environment_id,b.service_id,b.granted_by_user_id,b.granted_at
|
||||||
FROM gwf_access_bindings b
|
FROM gwf_access_bindings b
|
||||||
|
JOIN gwf_organizations o ON o.id=b.organization_id AND o.status='active'
|
||||||
WHERE b.organization_id=? AND b.revoked_at IS NULL
|
WHERE b.organization_id=? AND b.revoked_at IS NULL
|
||||||
AND EXISTS (SELECT 1 FROM gwf_organization_memberships m WHERE m.organization_id=b.organization_id AND m.user_id=? AND m.status='active')
|
AND EXISTS (SELECT 1 FROM gwf_organization_memberships m WHERE m.organization_id=b.organization_id AND m.user_id=? AND m.status='active')
|
||||||
AND ((b.subject_kind='user' AND b.subject_id=?) OR (b.subject_kind='team' AND EXISTS (SELECT 1 FROM gwf_team_members tm JOIN gwf_teams t ON t.id=tm.team_id WHERE tm.team_id=b.subject_id AND tm.user_id=? AND t.organization_id=b.organization_id)))
|
AND ((b.subject_kind='user' AND b.subject_id=?) OR (b.subject_kind='team' AND EXISTS (SELECT 1 FROM gwf_team_members tm JOIN gwf_teams t ON t.id=tm.team_id WHERE tm.team_id=b.subject_id AND tm.user_id=? AND t.organization_id=b.organization_id AND t.status='active')))
|
||||||
ORDER BY b.id`, organizationID, userID, userID, userID)
|
ORDER BY b.id`, organizationID, userID, userID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -133,6 +135,190 @@ func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userI
|
|||||||
return result, rows.Err()
|
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 {
|
||||||
|
return store.replaceOrganizationUserRoles(ctx, expected, []access.Binding{replacement}, ownerRole, audit, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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' AND u.registration_pending=0)`, 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' 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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
slices.Sort(currentRoles)
|
||||||
|
if slices.Equal(currentRoles, roles) {
|
||||||
|
return access.ErrRoleUnchanged
|
||||||
|
}
|
||||||
|
if requireOwner || slices.Contains(roles, ownerRole) || slices.Contains(currentRoles, ownerRole) {
|
||||||
|
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, replacement.Scope.OrganizationID, replacement.GrantedBy, ownerRole)
|
||||||
|
if ownerErr != nil {
|
||||||
|
return ownerErr
|
||||||
|
}
|
||||||
|
if !actorIsOwner {
|
||||||
|
return access.ErrOwnerAuthority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Contains(roles, 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' AND u.registration_pending=0
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
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 {
|
||||||
|
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 {
|
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 {
|
if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID {
|
||||||
return errors.New("authsqlite: invalid break-glass event")
|
return errors.New("authsqlite: invalid break-glass event")
|
||||||
@@ -142,6 +328,13 @@ func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlas
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
var active int
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_organizations o JOIN gwf_organization_memberships m ON m.organization_id=o.id WHERE o.id=? AND o.status='active' AND m.user_id=? AND m.status='active'`, grant.OrganizationID, grant.UserID).Scan(&active); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if active != 1 {
|
||||||
|
return errors.New("authsqlite: break-glass principal is not active in organization")
|
||||||
|
}
|
||||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_break_glass(id,organization_id,user_id,permission_name,reason,created_at,expires_at) VALUES(?,?,?,?,?,?,?)`, grant.ID, grant.OrganizationID, grant.UserID, grant.Permission, grant.Reason, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_break_glass(id,organization_id,user_id,permission_name,reason,created_at,expires_at) VALUES(?,?,?,?,?,?,?)`, grant.ID, grant.OrganizationID, grant.UserID, grant.Permission, grant.Reason, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -155,7 +348,7 @@ func (store *Store) ActiveBreakGlass(ctx context.Context, organizationID, userID
|
|||||||
if !opaqueID(organizationID) || !opaqueID(userID) || now.IsZero() {
|
if !opaqueID(organizationID) || !opaqueID(userID) || now.IsZero() {
|
||||||
return nil, errors.New("authsqlite: invalid break-glass query")
|
return nil, errors.New("authsqlite: invalid break-glass query")
|
||||||
}
|
}
|
||||||
rows, err := store.db.QueryContext(ctx, `SELECT id,permission_name,reason,created_at,expires_at FROM gwf_break_glass WHERE organization_id=? AND user_id=? AND expires_at>? ORDER BY expires_at`, organizationID, userID, now.Unix())
|
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.permission_name,b.reason,b.created_at,b.expires_at FROM gwf_break_glass b JOIN gwf_organizations o ON o.id=b.organization_id AND o.status='active' JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.user_id AND m.status='active' WHERE b.organization_id=? AND b.user_id=? AND b.expires_at>? ORDER BY b.expires_at`, organizationID, userID, now.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
"gamertan.com/web/account"
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (store *Store) CreateRegistration(ctx context.Context, registration account.Registration, passwordHash string, audit auth.AuditEvent) error {
|
||||||
|
user := registration.User
|
||||||
|
if zeroDigest(registration.Digest) || !validPendingUser(user) || !registration.CreatedAt.Equal(user.CreatedAt) || !registration.ExpiresAt.After(registration.CreatedAt) || registration.ExpiresAt.Sub(registration.CreatedAt) > time.Hour || !text(passwordHash, 1024, false) || !validAuditEvent(audit) || audit.ActorUserID != user.ID || audit.ResourceID != user.ID {
|
||||||
|
return errors.New("authsqlite: invalid account registration")
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
// A bounded abandoned registration must not reserve its email or username
|
||||||
|
// forever. Deleting the pending user cascades every private draft artifact.
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_users WHERE registration_pending=1 AND id IN (SELECT user_id FROM gwf_account_registrations WHERE expires_at<=?)`, registration.CreatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.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(?,?,?,?,?,?,?,0,1,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, user.ID, passwordHash, user.CreatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_registrations(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, registration.Digest[:], user.ID, registration.CreatedAt.Unix(), registration.ExpiresAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Registration(ctx context.Context, digest [32]byte, now time.Time) (account.Registration, error) {
|
||||||
|
if zeroDigest(digest) || now.IsZero() {
|
||||||
|
return account.Registration{}, account.ErrRegistrationNotFound
|
||||||
|
}
|
||||||
|
var registration account.Registration
|
||||||
|
var passwordChangeRequired, pending int
|
||||||
|
var created, updated, draftCreated, expires int64
|
||||||
|
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at,r.created_at,r.expires_at FROM gwf_account_registrations r JOIN gwf_users u ON u.id=r.user_id WHERE r.token_hash=? AND r.expires_at>? AND u.registration_pending=1`, digest[:], now.Unix()).Scan(®istration.User.ID, ®istration.User.Username, ®istration.User.Email, ®istration.User.DisplayName, ®istration.User.Status, &passwordChangeRequired, &pending, &created, &updated, &draftCreated, &expires)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return account.Registration{}, account.ErrRegistrationNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return account.Registration{}, err
|
||||||
|
}
|
||||||
|
registration.Digest = digest
|
||||||
|
registration.User.PasswordChangeRequired = passwordChangeRequired == 1
|
||||||
|
registration.User.RegistrationPending = pending == 1
|
||||||
|
registration.User.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
registration.User.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
|
registration.CreatedAt = time.Unix(draftCreated, 0).UTC()
|
||||||
|
registration.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||||
|
return registration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CompleteRegistration(ctx context.Context, digest [32]byte, completion account.RegistrationCompletion) error {
|
||||||
|
userID := completion.Membership.UserID
|
||||||
|
validOptionalCredential := completion.Credential == nil || validCredential(*completion.Credential, true) && completion.Credential.UserID == userID
|
||||||
|
if zeroDigest(digest) || !validOptionalCredential || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || !validOrganization(completion.Organization) || !completion.Organization.Personal || completion.Membership.OrganizationID != completion.Organization.ID || !opaqueID(userID) || completion.Membership.Status != "active" || completion.Membership.JoinedAt.IsZero() || !validOwnerBinding(completion.OwnerBinding, completion.Organization.ID, userID) || !validAuditEvent(completion.AuthAudit) || completion.AuthAudit.ActorUserID != userID || !validOrganizationAudit(completion.OrganizationAudit, completion.Organization.ID) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.OrganizationID != completion.Organization.ID || completion.CompletedAt.IsZero() {
|
||||||
|
return errors.New("authsqlite: invalid account registration completion")
|
||||||
|
}
|
||||||
|
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
|
||||||
|
for _, recoveryDigest := range completion.RecoveryDigests {
|
||||||
|
if zeroDigest(recoveryDigest) {
|
||||||
|
return errors.New("authsqlite: invalid recovery code digest")
|
||||||
|
}
|
||||||
|
if _, exists := seen[recoveryDigest]; exists {
|
||||||
|
return errors.New("authsqlite: duplicate recovery code digest")
|
||||||
|
}
|
||||||
|
seen[recoveryDigest] = struct{}{}
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var registeredUserID string
|
||||||
|
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_account_registrations WHERE token_hash=? AND expires_at>? RETURNING user_id`, digest[:], completion.CompletedAt.Unix()).Scan(®isteredUserID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return account.ErrRegistrationNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if registeredUserID != userID {
|
||||||
|
return account.ErrRegistrationNotFound
|
||||||
|
}
|
||||||
|
var pending int
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT registration_pending FROM gwf_users WHERE id=? AND status='active'`, userID).Scan(&pending); err != nil || pending != 1 {
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return account.ErrRegistrationNotFound
|
||||||
|
}
|
||||||
|
if completion.Credential != nil {
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, completion.Credential.ID, userID, completion.Credential.Label, []byte(completion.Credential.Data), completion.Credential.CreatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, recoveryDigest := range completion.RecoveryDigests {
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, recoveryDigest[:], completion.CompletedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
organization := completion.Organization
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,1,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, userID, organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, completion.Membership.OrganizationID, userID, completion.Membership.Status, completion.Membership.JoinedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
binding := completion.OwnerBinding
|
||||||
|
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=?`, binding.ID, organization.ID, userID, binding.Role, userID, binding.GrantedAt.Unix(), binding.Role)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 {
|
||||||
|
if rowsErr != nil {
|
||||||
|
return rowsErr
|
||||||
|
}
|
||||||
|
return errors.New("authsqlite: account owner role has not been seeded")
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET registration_pending=0,updated_at=? WHERE id=? AND registration_pending=1`, completion.CompletedAt.Unix(), userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, completion.AuthAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, completion.OrganizationAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPendingUser(user auth.User) bool {
|
||||||
|
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && user.RegistrationPending && !user.PasswordChangeRequired && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validOwnerBinding(binding access.Binding, organizationID, userID string) bool {
|
||||||
|
return opaqueID(binding.ID) && binding.SubjectKind == access.User && binding.SubjectID == userID && safeName(binding.Role) && binding.Scope.OrganizationID == organizationID && binding.Scope.ProjectID == "" && binding.Scope.EnvironmentID == "" && binding.Scope.ServiceID == "" && binding.GrantedBy == userID && !binding.GrantedAt.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ account.Repository = (*Store)(nil)
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
"gamertan.com/web/account"
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/authwebauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccountRegistrationCommitsEveryRequiredArtifact(t *testing.T) {
|
||||||
|
store, authService, accountService, passkeys := accountFixture(t, true)
|
||||||
|
started, err := accountService.Start(t.Context(), account.StartInput{Email: "PERSON@example.test", Username: "person.one", DisplayName: "Person One", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if started.User.Email != "person@example.test" || !started.User.RegistrationPending {
|
||||||
|
t.Fatalf("pending user=%+v", started.User)
|
||||||
|
}
|
||||||
|
if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); !errors.Is(err, auth.ErrInactiveUser) {
|
||||||
|
t.Fatalf("pending password verification err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = accountService.BeginPasskey(t.Context(), started.RegistrationToken, "Primary passkey"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
finished, err := accountService.FinishWithPasskey(t.Context(), started.RegistrationToken, "ceremony-token", []byte(`{"id":"fixture"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if finished.User.RegistrationPending || finished.User.ID != started.User.ID || len(finished.RecoveryCodes) != 10 || finished.SessionToken == "" || !finished.Organization.Personal {
|
||||||
|
t.Fatalf("finish=%+v code-count=%d", finished, len(finished.RecoveryCodes))
|
||||||
|
}
|
||||||
|
if passkeys.userID != started.User.ID || passkeys.binding != started.RegistrationToken {
|
||||||
|
t.Fatalf("passkey binding user=%q binding=%q", passkeys.userID, passkeys.binding)
|
||||||
|
}
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 1)
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 10)
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organizations WHERE personal_owner_user_id=?`, started.User.ID, 1)
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND role_name='owner'`, started.User.ID, 1)
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_account_registrations WHERE user_id=?`, started.User.ID, 0)
|
||||||
|
if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); err != nil {
|
||||||
|
t.Fatalf("completed password verification: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordAccountCanFinishWithoutPasskey(t *testing.T) {
|
||||||
|
store, authService, accountService, _ := accountFixture(t, true)
|
||||||
|
started, err := accountService.Start(t.Context(), account.StartInput{Email: "reader@example.test", Username: "reader.one", DisplayName: "Reader One", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
finished, err := accountService.FinishPassword(t.Context(), started.RegistrationToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if finished.SessionToken == "" || len(finished.RecoveryCodes) != 10 || len(finished.PasskeyCredential.ID) != 0 {
|
||||||
|
t.Fatalf("password finish=%+v code-count=%d", finished, len(finished.RecoveryCodes))
|
||||||
|
}
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 0)
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 10)
|
||||||
|
if _, err = authService.VerifyPassword(t.Context(), "reader@example.test", "correct horse battery staple"); err != nil {
|
||||||
|
t.Fatalf("password account not active: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountRegistrationRollsBackWhenOwnerPolicyIsMissing(t *testing.T) {
|
||||||
|
store, authService, accountService, _ := accountFixture(t, false)
|
||||||
|
started, err := accountService.Start(t.Context(), account.StartInput{Email: "rollback@example.test", Username: "rollback.one", DisplayName: "Rollback One", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = accountService.BeginPasskey(t.Context(), started.RegistrationToken, "Primary passkey"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = accountService.FinishWithPasskey(t.Context(), started.RegistrationToken, "ceremony-token", []byte(`{"id":"fixture"}`)); err == nil {
|
||||||
|
t.Fatal("completion unexpectedly succeeded without seeded owner role")
|
||||||
|
}
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 0)
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 0)
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organizations WHERE personal_owner_user_id=?`, started.User.ID, 0)
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_account_registrations WHERE user_id=?`, started.User.ID, 1)
|
||||||
|
if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); !errors.Is(err, auth.ErrInactiveUser) {
|
||||||
|
t.Fatalf("rolled-back account became usable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountFixture(t *testing.T, seedOwner bool) (*Store, *auth.Service, *account.Service, *accountPasskeys) {
|
||||||
|
t.Helper()
|
||||||
|
store, err := Open(filepath.Join(t.TempDir(), "identity.sqlite"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = store.Close() })
|
||||||
|
if seedOwner {
|
||||||
|
err = store.SeedAccessPolicy(t.Context(), access.Policy{
|
||||||
|
Roles: map[string]string{"owner": "Personal organization owner"},
|
||||||
|
Permissions: map[string]string{"account.view": "View the account"},
|
||||||
|
Grants: map[string][]string{"owner": {"account.view"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authService, err := auth.New(store, auth.Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
passkeys := &accountPasskeys{now: time.Now().UTC()}
|
||||||
|
accountService, err := account.New(store, passkeys, authService, account.Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return store, authService, accountService, passkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountPasskeys struct {
|
||||||
|
userID, binding string
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (passkeys *accountPasskeys) BeginAccountRegistration(_ context.Context, userID, _ string, binding []byte) (authwebauthn.BeginResult, error) {
|
||||||
|
passkeys.userID = userID
|
||||||
|
passkeys.binding = string(binding)
|
||||||
|
return authwebauthn.BeginResult{CeremonyToken: "ceremony-token", PublicKey: []byte(`{}`), ExpiresAt: passkeys.now.Add(5 * time.Minute)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (passkeys *accountPasskeys) FinishAccountRegistration(ctx context.Context, ceremonyToken string, binding, _ []byte, commit authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) {
|
||||||
|
if ceremonyToken != "ceremony-token" || string(binding) != passkeys.binding {
|
||||||
|
return authwebauthn.Credential{}, authwebauthn.ErrOperationBinding
|
||||||
|
}
|
||||||
|
credential := authwebauthn.Credential{ID: []byte("fixture-credential-id"), UserID: passkeys.userID, Label: "Primary passkey", Data: []byte(`{"id":"fixture-credential-id"}`), CreatedAt: passkeys.now}
|
||||||
|
audit := auth.AuditEvent{ID: "passkey-audit-id", ActorUserID: passkeys.userID, Action: "auth.account.passkey", ResourceType: "passkey", ResourceID: "fixture-credential-id", Summary: "The initial account passkey was enrolled.", CreatedAt: passkeys.now}
|
||||||
|
if err := commit(ctx, credential, audit); err != nil {
|
||||||
|
return authwebauthn.Credential{}, err
|
||||||
|
}
|
||||||
|
return credential, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCount(t *testing.T, store *Store, query, id string, want int) {
|
||||||
|
t.Helper()
|
||||||
|
var got int
|
||||||
|
if err := store.db.QueryRow(query, id).Scan(&got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("count for %q = %d, want %d", query, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/authrecovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (store *Store) IssueAssistedRecovery(ctx context.Context, grant authrecovery.AssistedGrant, ownerRole string, authAudit auth.AuditEvent, accessAudit access.AuditEvent) (auth.User, error) {
|
||||||
|
if !validAssistedGrant(grant) || !safeName(ownerRole) || !validAuditEvent(authAudit) || authAudit.ActorUserID != grant.IssuedByUserID || authAudit.Action != "auth.assisted-recovery.issue" || authAudit.ResourceType != "user" || authAudit.ResourceID != grant.UserID || !authAudit.CreatedAt.Equal(grant.CreatedAt) || !validAccessAudit(accessAudit) || accessAudit.OrganizationID != grant.OrganizationID || accessAudit.ActorUserID != grant.IssuedByUserID || accessAudit.Action != "access.account-recovery.issue" || accessAudit.ResourceType != "user" || accessAudit.ResourceID != grant.UserID || !accessAudit.CreatedAt.Equal(grant.CreatedAt) {
|
||||||
|
return auth.User{}, errors.New("authsqlite: invalid assisted recovery issue")
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Take the SQLite write lock before checking owner authority so a role or
|
||||||
|
// membership mutation cannot race the reviewed recovery decision.
|
||||||
|
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' AND u.registration_pending=0)
|
||||||
|
AND EXISTS (SELECT 1 FROM gwf_access_bindings b 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)`, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, ownerRole)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return auth.User{}, authrecovery.ErrAssistedDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at
|
||||||
|
FROM gwf_users u JOIN gwf_organization_memberships m ON m.user_id=u.id
|
||||||
|
WHERE u.id=? AND u.status='active' AND u.registration_pending=0 AND m.organization_id=? AND m.status='active'`, grant.UserID, grant.OrganizationID))
|
||||||
|
if errors.Is(err, auth.ErrUserNotFound) {
|
||||||
|
return auth.User{}, authrecovery.ErrAssistedDenied
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, statement := range []string{
|
||||||
|
`DELETE FROM gwf_auth_sessions WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_recovery_grants WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_password_credentials WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_passkey_credentials WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_recovery_codes WHERE user_id=?`,
|
||||||
|
} {
|
||||||
|
if _, err = tx.ExecContext(ctx, statement, grant.UserID); err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_assisted_recovery_grants(token_hash,user_id,organization_id,issued_by_user_id,created_at,expires_at) VALUES(?,?,?,?,?,?)`, grant.Digest[:], grant.UserID, grant.OrganizationID, grant.IssuedByUserID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, authAudit); err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
if err = appendAccessAudit(ctx, tx, accessAudit); err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) AssistedRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (authrecovery.AssistedGrant, auth.User, error) {
|
||||||
|
if zeroDigest(digest) || now.IsZero() {
|
||||||
|
return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound
|
||||||
|
}
|
||||||
|
var grant authrecovery.AssistedGrant
|
||||||
|
var user auth.User
|
||||||
|
var created, expires, userCreated, userUpdated int64
|
||||||
|
var passwordChangeRequired, registrationPending int
|
||||||
|
err := store.db.QueryRowContext(ctx, `SELECT g.user_id,g.organization_id,g.issued_by_user_id,g.created_at,g.expires_at,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at
|
||||||
|
FROM gwf_assisted_recovery_grants g
|
||||||
|
JOIN gwf_users u ON u.id=g.user_id AND u.status='active' AND u.registration_pending=0
|
||||||
|
JOIN gwf_organizations o ON o.id=g.organization_id AND o.status='active'
|
||||||
|
JOIN gwf_organization_memberships m ON m.organization_id=g.organization_id AND m.user_id=g.user_id AND m.status='active'
|
||||||
|
WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()).Scan(&grant.UserID, &grant.OrganizationID, &grant.IssuedByUserID, &created, &expires, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, ®istrationPending, &userCreated, &userUpdated)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return authrecovery.AssistedGrant{}, auth.User{}, err
|
||||||
|
}
|
||||||
|
grant.Digest, grant.CreatedAt, grant.ExpiresAt = digest, time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
|
||||||
|
user.ID, user.PasswordChangeRequired, user.RegistrationPending = grant.UserID, passwordChangeRequired == 1, registrationPending == 1
|
||||||
|
user.CreatedAt, user.UpdatedAt = time.Unix(userCreated, 0).UTC(), time.Unix(userUpdated, 0).UTC()
|
||||||
|
return grant, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CompleteAssistedRecovery(ctx context.Context, completion authrecovery.AssistedCompletion) error {
|
||||||
|
credential := completion.Credential
|
||||||
|
credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID)
|
||||||
|
if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || !text(completion.PasswordHash, 1024, false) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || !validAuditEvent(completion.RecoveryAudit) || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.assisted-recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID || !completion.RecoveryAudit.CreatedAt.Equal(completion.CompletedAt) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.ActorUserID != credential.UserID || completion.AccessAudit.Action != "access.account-recovery.complete" || completion.AccessAudit.ResourceType != "user" || completion.AccessAudit.ResourceID != credential.UserID || !completion.AccessAudit.CreatedAt.Equal(completion.CompletedAt) {
|
||||||
|
return errors.New("authsqlite: invalid assisted recovery completion")
|
||||||
|
}
|
||||||
|
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
|
||||||
|
for _, digest := range completion.RecoveryDigests {
|
||||||
|
if zeroDigest(digest) {
|
||||||
|
return errors.New("authsqlite: invalid assisted recovery-code digest")
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[digest]; duplicate {
|
||||||
|
return errors.New("authsqlite: duplicate assisted recovery-code digest")
|
||||||
|
}
|
||||||
|
seen[digest] = struct{}{}
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var userID, organizationID string
|
||||||
|
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_assisted_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id,organization_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID, &organizationID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return authrecovery.ErrAssistedNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if userID != credential.UserID || organizationID != completion.AccessAudit.OrganizationID {
|
||||||
|
return errors.New("authsqlite: assisted recovery identity mismatch")
|
||||||
|
}
|
||||||
|
var active int
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users u
|
||||||
|
JOIN gwf_organization_memberships m ON m.user_id=u.id AND m.organization_id=? AND m.status='active'
|
||||||
|
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
|
||||||
|
WHERE u.id=? AND u.status='active' AND u.registration_pending=0`, organizationID, userID).Scan(&active); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if active != 1 {
|
||||||
|
return auth.ErrInactiveUser
|
||||||
|
}
|
||||||
|
for _, statement := range []string{
|
||||||
|
`DELETE FROM gwf_auth_sessions WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_recovery_grants WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_password_credentials WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_passkey_credentials WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_recovery_codes WHERE user_id=?`,
|
||||||
|
} {
|
||||||
|
if _, err = tx.ExecContext(ctx, statement, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, userID, completion.PasswordHash, completion.CompletedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, userID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, digest := range completion.RecoveryDigests {
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, digest[:], completion.CompletedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, completion.CompletedAt.Unix(), userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, completion.PasskeyAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, completion.RecoveryAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAssistedGrant(grant authrecovery.AssistedGrant) bool {
|
||||||
|
return !zeroDigest(grant.Digest) && opaqueID(grant.OrganizationID) && opaqueID(grant.UserID) && opaqueID(grant.IssuedByUserID) && !grant.CreatedAt.IsZero() && grant.ExpiresAt.After(grant.CreatedAt) && grant.ExpiresAt.Sub(grant.CreatedAt) >= 5*time.Minute && grant.ExpiresAt.Sub(grant.CreatedAt) <= 30*time.Minute
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gamertan.com/web/bootstrap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateInitialOwner commits the root-local bootstrap across identity,
|
||||||
|
// enrollment, organization, membership, owner access, and all audit records.
|
||||||
|
func (store *Store) CreateInitialOwner(ctx context.Context, setup bootstrap.Setup) error {
|
||||||
|
user := setup.User
|
||||||
|
organization := setup.Organization
|
||||||
|
membership := setup.Membership
|
||||||
|
binding := setup.OwnerBinding
|
||||||
|
if !validPasskeyUser(user) || !validEnrollment(setup.Enrollment) || setup.Enrollment.UserID != user.ID ||
|
||||||
|
!validOrganization(organization) || organization.Personal || organization.Status != "active" || organization.Revision != 1 ||
|
||||||
|
membership.OrganizationID != organization.ID || membership.UserID != user.ID || membership.Status != "active" || membership.JoinedAt.IsZero() ||
|
||||||
|
!validOwnerBinding(binding, organization.ID, user.ID) ||
|
||||||
|
!validAuditEvent(setup.AuthAudit) || setup.AuthAudit.ActorUserID != user.ID || setup.AuthAudit.Action != "auth.passkey.bootstrap" || setup.AuthAudit.ResourceType != "user" || setup.AuthAudit.ResourceID != user.ID ||
|
||||||
|
!validOrganizationAudit(setup.OrganizationAudit, organization.ID) || setup.OrganizationAudit.ActorUserID != user.ID || setup.OrganizationAudit.Action != "organization.bootstrap" || setup.OrganizationAudit.ResourceType != "organization" || setup.OrganizationAudit.ResourceID != organization.ID ||
|
||||||
|
!validAccessAudit(setup.AccessAudit) || setup.AccessAudit.OrganizationID != organization.ID || setup.AccessAudit.ActorUserID != user.ID || setup.AccessAudit.Action != "access.binding.grant" || setup.AccessAudit.ResourceType != "binding" || setup.AccessAudit.ResourceID != binding.ID {
|
||||||
|
return errors.New("authsqlite: invalid initial owner bootstrap")
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err = tx.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(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, setup.Enrollment.Digest[:], user.ID, setup.Enrollment.CreatedAt.Unix(), setup.Enrollment.ExpiresAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,0,NULL,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, organization.ID, user.ID, membership.Status, membership.JoinedAt.Unix()); 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=?`, binding.ID, organization.ID, user.ID, binding.Role, user.ID, binding.GrantedAt.Unix(), binding.Role)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 {
|
||||||
|
if rowsErr != nil {
|
||||||
|
return rowsErr
|
||||||
|
}
|
||||||
|
return errors.New("authsqlite: initial owner role has not been seeded")
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, setup.AuthAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, setup.OrganizationAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAccessAudit(ctx, tx, setup.AccessAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ bootstrap.Repository = (*Store)(nil)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/authwebauthn"
|
||||||
|
"gamertan.com/web/bootstrap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitialOwnerBootstrapCommitsEveryBoundary(t *testing.T) {
|
||||||
|
store, err := Open(t.TempDir() + "/bootstrap.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
policy := access.Policy{Roles: map[string]string{"home.owner": "Own the home organization"}, Permissions: map[string]string{"home.manage": "Manage the home organization"}, Grants: map[string][]string{"home.owner": {"home.manage"}}}
|
||||||
|
accessService, err := access.New(store, policy, access.Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = accessService.Seed(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC)
|
||||||
|
service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
created, err := service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user, err := store.UserByID(t.Context(), created.User.ID)
|
||||||
|
if err != nil || user.Email != "cole@example.test" {
|
||||||
|
t.Fatalf("user=%+v err=%v", user, err)
|
||||||
|
}
|
||||||
|
organization, err := store.OrganizationByID(t.Context(), created.Organization.ID)
|
||||||
|
if err != nil || organization.Personal || organization.Slug != "gamertan" {
|
||||||
|
t.Fatalf("organization=%+v err=%v", organization, err)
|
||||||
|
}
|
||||||
|
memberships, err := store.MembershipsForUser(t.Context(), user.ID)
|
||||||
|
if err != nil || len(memberships) != 1 || memberships[0].OrganizationID != organization.ID {
|
||||||
|
t.Fatalf("memberships=%+v err=%v", memberships, err)
|
||||||
|
}
|
||||||
|
decision, err := accessService.Authorize(t.Context(), user.ID, access.Scope{OrganizationID: organization.ID}, "home.manage")
|
||||||
|
if err != nil || !decision.Allowed || decision.Role != "home.owner" {
|
||||||
|
t.Fatalf("decision=%+v err=%v", decision, err)
|
||||||
|
}
|
||||||
|
passkeyService := testBootstrapPasskeyService(t, store, now)
|
||||||
|
begin, err := passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Initial passkey")
|
||||||
|
if err != nil || begin.CeremonyToken == "" {
|
||||||
|
t.Fatalf("begin=%+v err=%v", begin, err)
|
||||||
|
}
|
||||||
|
if _, err = passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
|
||||||
|
t.Fatalf("enrollment replay err=%v", err)
|
||||||
|
}
|
||||||
|
var authAudits, accessAudits int
|
||||||
|
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&authAudits); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&accessAudits); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if authAudits != 1 || accessAudits != 2 {
|
||||||
|
t.Fatalf("auth audits=%d access audits=%d", authAudits, accessAudits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialOwnerBootstrapRollsBackWithoutSeededRole(t *testing.T) {
|
||||||
|
store, err := Open(t.TempDir() + "/bootstrap.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC)
|
||||||
|
service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"}); err == nil {
|
||||||
|
t.Fatal("bootstrap succeeded without seeded role")
|
||||||
|
}
|
||||||
|
for _, table := range []string{"gwf_users", "gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_passkey_enrollment_tokens", "gwf_audit_events", "gwf_access_audit_events"} {
|
||||||
|
var count int
|
||||||
|
if queryErr := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); queryErr != nil || count != 0 {
|
||||||
|
t.Fatalf("table=%s count=%d err=%v", table, count, queryErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBootstrapPasskeyService(t *testing.T, store *Store, now time.Time) *authwebauthn.Service {
|
||||||
|
t.Helper()
|
||||||
|
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "example.test", RPDisplayName: "Example", Origin: "https://example.test", Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+793
-27
@@ -5,14 +5,17 @@ package authsqlite
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gamertan.com/web/organizations"
|
"gamertan.com/web/organizations"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (store *Store) CreateOrganization(ctx context.Context, organization organizations.Organization, owner organizations.Membership) error {
|
func (store *Store) CreateOrganization(ctx context.Context, organization organizations.Organization, owner organizations.Membership, audit organizations.AuditEvent) error {
|
||||||
if !opaqueID(organization.ID) || !slugValue(organization.Slug) || !text(organization.Name, 128, false) || organization.CreatedAt.IsZero() || owner.OrganizationID != organization.ID || !opaqueID(owner.UserID) || owner.Status != "active" || owner.JoinedAt.IsZero() {
|
if !validOrganization(organization) || owner.OrganizationID != organization.ID || !opaqueID(owner.UserID) || owner.Status != "active" || owner.JoinedAt.IsZero() || !validOrganizationAudit(audit, organization.ID) {
|
||||||
return errors.New("authsqlite: invalid organization")
|
return errors.New("authsqlite: invalid organization")
|
||||||
}
|
}
|
||||||
tx, err := store.db.BeginTx(ctx, nil)
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
@@ -24,38 +27,64 @@ func (store *Store) CreateOrganization(ctx context.Context, organization organiz
|
|||||||
if organization.Personal {
|
if organization.Personal {
|
||||||
personalOwner = owner.UserID
|
personalOwner = owner.UserID
|
||||||
}
|
}
|
||||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at) VALUES(?,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner, organization.CreatedAt.Unix()); err != nil {
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner, organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, owner.OrganizationID, owner.UserID, owner.Status, owner.JoinedAt.Unix()); err != nil {
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, owner.OrganizationID, owner.UserID, owner.Status, owner.JoinedAt.Unix()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) CreateTeam(ctx context.Context, team organizations.Team) error {
|
func (store *Store) CreateTeam(ctx context.Context, team organizations.Team, audit organizations.AuditEvent) error {
|
||||||
if !opaqueID(team.ID) || !opaqueID(team.OrganizationID) || !slugValue(team.Slug) || !text(team.Name, 128, false) || team.CreatedAt.IsZero() {
|
if !validTeam(team) || !validOrganizationAudit(audit, team.OrganizationID) {
|
||||||
return errors.New("authsqlite: invalid team")
|
return errors.New("authsqlite: invalid team")
|
||||||
}
|
}
|
||||||
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_teams(id,organization_id,slug,name,created_at) VALUES(?,?,?,?,?)`, team.ID, team.OrganizationID, team.Slug, team.Name, team.CreatedAt.Unix())
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_teams(id,organization_id,slug,name,created_at,status,revision,updated_at) SELECT ?,?,?,?,?,?,?,? FROM gwf_organizations WHERE id=? AND status='active'`, team.ID, team.OrganizationID, team.Slug, team.Name, team.CreatedAt.Unix(), team.Status, team.Revision, team.UpdatedAt.Unix(), team.OrganizationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return organizations.ErrOrganizationNotFound
|
||||||
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) AddTeamMember(ctx context.Context, membership organizations.TeamMembership) error {
|
func (store *Store) AddTeamMember(ctx context.Context, membership organizations.TeamMembership, audit organizations.AuditEvent) error {
|
||||||
if !opaqueID(membership.TeamID) || !opaqueID(membership.UserID) || membership.JoinedAt.IsZero() {
|
if !opaqueID(membership.TeamID) || !opaqueID(membership.UserID) || membership.JoinedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) {
|
||||||
return errors.New("authsqlite: invalid team membership")
|
return errors.New("authsqlite: invalid team membership")
|
||||||
}
|
}
|
||||||
result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_team_members(team_id,user_id,joined_at)
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_team_members(team_id,user_id,joined_at)
|
||||||
SELECT t.id,?,? FROM gwf_teams t
|
SELECT t.id,?,? FROM gwf_teams t
|
||||||
JOIN gwf_organization_memberships m ON m.organization_id=t.organization_id AND m.user_id=? AND m.status='active'
|
JOIN gwf_organization_memberships m ON m.organization_id=t.organization_id AND m.user_id=? AND m.status='active'
|
||||||
WHERE t.id=? ON CONFLICT(team_id,user_id) DO UPDATE SET joined_at=gwf_team_members.joined_at`, membership.UserID, membership.JoinedAt.Unix(), membership.UserID, membership.TeamID)
|
JOIN gwf_organizations o ON o.id=t.organization_id AND o.status='active'
|
||||||
|
WHERE t.id=? AND t.status='active' AND t.organization_id=? ON CONFLICT(team_id,user_id) DO UPDATE SET joined_at=gwf_team_members.joined_at`, membership.UserID, membership.JoinedAt.Unix(), membership.UserID, membership.TeamID, audit.OrganizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
return organizations.ErrMembershipNotFound
|
return organizations.ErrMembershipNotFound
|
||||||
}
|
}
|
||||||
return nil
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) CreateProject(ctx context.Context, project organizations.Project) error {
|
func (store *Store) CreateProject(ctx context.Context, project organizations.Project) error {
|
||||||
@@ -96,20 +125,74 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation) error {
|
func (store *Store) CreateInvitationWithRoles(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
|
||||||
if zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() {
|
return store.CreateInvitation(ctx, invitation, ownerRole, audit)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
return errors.New("authsqlite: invalid invitation")
|
return errors.New("authsqlite: invalid invitation")
|
||||||
}
|
}
|
||||||
result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at)
|
roles, err := invitation.RoleNames()
|
||||||
SELECT ?,?,?,?,?,? FROM gwf_organization_memberships
|
if err != nil {
|
||||||
WHERE organization_id=? AND user_id=? AND status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.OrganizationID, invitation.InvitedByUserID)
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if invitation.RequiredOwnerRole != "" {
|
||||||
|
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, invitation.RequiredOwnerRole)
|
||||||
|
if ownerErr != nil {
|
||||||
|
return ownerErr
|
||||||
|
}
|
||||||
|
if !actorIsOwner {
|
||||||
|
return organizations.ErrOwnerAuthority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, role := range roles {
|
||||||
|
var count int
|
||||||
|
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
|
||||||
}
|
}
|
||||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
return organizations.ErrMembershipNotFound
|
return organizations.ErrMembershipNotFound
|
||||||
}
|
}
|
||||||
return nil
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now time.Time) (organizations.Invitation, error) {
|
func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now time.Time) (organizations.Invitation, error) {
|
||||||
@@ -118,7 +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
|
||||||
err := store.db.QueryRowContext(ctx, `SELECT organization_id,email_normalized,invited_by_user_id,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &created, &expires)
|
var teamIDs, rolesJSON []byte
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -126,13 +210,23 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now
|
|||||||
return organizations.Invitation{}, err
|
return organizations.Invitation{}, err
|
||||||
}
|
}
|
||||||
invitation.Digest = digest
|
invitation.Digest = digest
|
||||||
|
if err = json.Unmarshal(teamIDs, &invitation.TeamIDs); err != nil || !validInvitationTeamIDs(invitation.TeamIDs) {
|
||||||
|
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) 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() {
|
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)
|
||||||
@@ -140,17 +234,72 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
var organizationID string
|
// Serialize acceptance before reading token state, including competing users.
|
||||||
err = tx.QueryRowContext(ctx, `SELECT i.organization_id FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized WHERE i.token_hash=? AND i.used_at IS NULL AND i.expires_at>?`, userID, digest[:], acceptedAt.Unix()).Scan(&organizationID)
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET expires_at=expires_at WHERE token_hash=?`, digest[:]); err != nil {
|
||||||
|
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
|
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
|
||||||
|
}
|
||||||
|
var teamIDs []string
|
||||||
|
if json.Unmarshal(teamIDsJSON, &teamIDs) != nil || !validInvitationTeamIDs(teamIDs) {
|
||||||
|
return organizations.ErrInvitationNotFound
|
||||||
|
}
|
||||||
|
if err = validateInvitationTeams(ctx, tx, organizationID, teamIDs); err != nil {
|
||||||
|
return organizations.ErrInvitationNotFound
|
||||||
|
}
|
||||||
|
for _, teamID := range teamIDs {
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_team_members(team_id,user_id,joined_at) VALUES(?,?,?) ON CONFLICT(team_id,user_id) DO NOTHING`, teamID, userID, acceptedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, role := range roles {
|
||||||
|
bindingID := "invite-" + invitationID
|
||||||
|
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=?`, bindingID, organizationID, userID, role, invitedBy, acceptedAt.Unix(), role)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return organizations.ErrInvitationNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET used_at=? WHERE token_hash=? AND used_at IS NULL`, acceptedAt.Unix(), digest[:])
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET used_at=? WHERE token_hash=? AND used_at IS NULL`, acceptedAt.Unix(), digest[:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -158,6 +307,9 @@ 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 err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,11 +336,39 @@ func (store *Store) MembershipsForUser(ctx context.Context, userID string) ([]or
|
|||||||
return result, rows.Err()
|
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) {
|
func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID string) ([]organizations.Team, error) {
|
||||||
if !opaqueID(organizationID) || !opaqueID(userID) {
|
if !opaqueID(organizationID) || !opaqueID(userID) {
|
||||||
return nil, errors.New("authsqlite: invalid team query")
|
return nil, errors.New("authsqlite: invalid team query")
|
||||||
}
|
}
|
||||||
rows, err := store.db.QueryContext(ctx, `SELECT t.id,t.slug,t.name,t.created_at FROM gwf_teams t JOIN gwf_team_members tm ON tm.team_id=t.id WHERE t.organization_id=? AND tm.user_id=? ORDER BY t.slug`, organizationID, userID)
|
rows, err := store.db.QueryContext(ctx, `SELECT t.id,t.slug,t.name,t.status,t.revision,t.created_at,t.updated_at FROM gwf_teams t JOIN gwf_team_members tm ON tm.team_id=t.id JOIN gwf_organizations o ON o.id=t.organization_id WHERE t.organization_id=? AND tm.user_id=? AND t.status='active' AND o.status='active' ORDER BY t.slug`, organizationID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -196,17 +376,603 @@ func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID str
|
|||||||
var result []organizations.Team
|
var result []organizations.Team
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var team organizations.Team
|
var team organizations.Team
|
||||||
var created int64
|
var created, updated int64
|
||||||
if err = rows.Scan(&team.ID, &team.Slug, &team.Name, &created); err != nil {
|
if err = rows.Scan(&team.ID, &team.Slug, &team.Name, &team.Status, &team.Revision, &created, &updated); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
team.OrganizationID = organizationID
|
team.OrganizationID = organizationID
|
||||||
team.CreatedAt = time.Unix(created, 0).UTC()
|
team.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
team.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
result = append(result, team)
|
result = append(result, team)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) OrganizationByID(ctx context.Context, organizationID string) (organizations.Organization, error) {
|
||||||
|
if !opaqueID(organizationID) {
|
||||||
|
return organizations.Organization{}, organizations.ErrOrganizationNotFound
|
||||||
|
}
|
||||||
|
var value organizations.Organization
|
||||||
|
var personal int
|
||||||
|
var created, updated int64
|
||||||
|
err := store.db.QueryRowContext(ctx, `SELECT id,slug,name,status,personal,revision,created_at,updated_at FROM gwf_organizations WHERE id=?`, organizationID).Scan(&value.ID, &value.Slug, &value.Name, &value.Status, &personal, &value.Revision, &created, &updated)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return organizations.Organization{}, organizations.ErrOrganizationNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return organizations.Organization{}, err
|
||||||
|
}
|
||||||
|
value.Personal = personal == 1
|
||||||
|
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return errors.New("authsqlite: invalid organization update")
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return organizations.ErrRevisionConflict
|
||||||
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) TeamByID(ctx context.Context, organizationID, teamID string) (organizations.Team, error) {
|
||||||
|
if !opaqueID(teamID) || organizationID != "" && !opaqueID(organizationID) {
|
||||||
|
return organizations.Team{}, organizations.ErrTeamNotFound
|
||||||
|
}
|
||||||
|
query := `SELECT id,organization_id,slug,name,status,revision,created_at,updated_at FROM gwf_teams WHERE id=?`
|
||||||
|
args := []any{teamID}
|
||||||
|
if organizationID != "" {
|
||||||
|
query += ` AND organization_id=?`
|
||||||
|
args = append(args, organizationID)
|
||||||
|
}
|
||||||
|
var value organizations.Team
|
||||||
|
var created, updated int64
|
||||||
|
err := store.db.QueryRowContext(ctx, query, args...).Scan(&value.ID, &value.OrganizationID, &value.Slug, &value.Name, &value.Status, &value.Revision, &created, &updated)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return organizations.Team{}, organizations.ErrTeamNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return organizations.Team{}, err
|
||||||
|
}
|
||||||
|
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) UpdateTeam(ctx context.Context, value organizations.Team, expectedRevision int64, audit organizations.AuditEvent) error {
|
||||||
|
if !validTeam(value) || expectedRevision < 1 || value.Revision != expectedRevision+1 || !validOrganizationAudit(audit, value.OrganizationID) {
|
||||||
|
return errors.New("authsqlite: invalid team update")
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_teams SET slug=?,name=?,status=?,revision=?,updated_at=? WHERE id=? AND organization_id=? AND revision=?`, value.Slug, value.Name, value.Status, value.Revision, value.UpdatedAt.Unix(), value.ID, value.OrganizationID, expectedRevision)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return organizations.ErrRevisionConflict
|
||||||
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) RemoveTeamMember(ctx context.Context, teamID, userID string, audit organizations.AuditEvent) error {
|
||||||
|
if !opaqueID(teamID) || !opaqueID(userID) || !validOrganizationAudit(audit, audit.OrganizationID) {
|
||||||
|
return organizations.ErrMembershipNotFound
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE team_id=? AND user_id=? AND EXISTS (SELECT 1 FROM gwf_teams WHERE id=? AND organization_id=?)`, teamID, userID, teamID, audit.OrganizationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return organizations.ErrMembershipNotFound
|
||||||
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) SetMembershipStatus(ctx context.Context, organizationID, userID, status, ownerRole string, audit organizations.AuditEvent) error {
|
||||||
|
if !opaqueID(organizationID) || !opaqueID(userID) || (status != "active" && status != "suspended") || status != "active" && !safeName(ownerRole) || !validOrganizationAudit(audit, organizationID) {
|
||||||
|
return organizations.ErrMembershipNotFound
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, organizationID, audit.ActorUserID, userID, ownerRole); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status != "active" {
|
||||||
|
if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=? WHERE organization_id=? AND user_id=?`, status, organizationID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return organizations.ErrMembershipNotFound
|
||||||
|
}
|
||||||
|
if status != "active" {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return organizations.ErrMembershipNotFound
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if current != input.ExpectedStatus {
|
||||||
|
return organizations.ErrRevisionConflict
|
||||||
|
}
|
||||||
|
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, input.OrganizationID, input.ActorUserID, input.UserID, ownerRole); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if input.Status == "suspended" {
|
||||||
|
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=? WHERE organization_id=? AND user_id=? AND status=?`, input.Status, input.OrganizationID, input.UserID, input.ExpectedStatus)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return organizations.ErrRevisionConflict
|
||||||
|
}
|
||||||
|
if input.Status == "suspended" {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID, ownerRole string, audit organizations.AuditEvent) error {
|
||||||
|
if !opaqueID(organizationID) || !opaqueID(userID) || !safeName(ownerRole) || !validOrganizationAudit(audit, organizationID) {
|
||||||
|
return organizations.ErrMembershipNotFound
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, organizationID, audit.ActorUserID, userID, ownerRole); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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 revoked_at IS NULL`, audit.ActorUserID, audit.CreatedAt.Unix(), organizationID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_organization_memberships WHERE organization_id=? AND user_id=?`, organizationID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return organizations.ErrMembershipNotFound
|
||||||
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return organizations.ErrMembershipNotFound
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if current != input.ExpectedStatus {
|
||||||
|
return organizations.ErrRevisionConflict
|
||||||
|
}
|
||||||
|
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, input.OrganizationID, input.ActorUserID, input.UserID, ownerRole); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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 revoked_at IS NULL`, audit.ActorUserID, audit.CreatedAt.Unix(), input.OrganizationID, input.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status=?`, input.OrganizationID, input.UserID, input.ExpectedStatus)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return organizations.ErrRevisionConflict
|
||||||
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
// Acquire the SQLite write lock before reading the optimistic state. This
|
||||||
|
// makes a competing lifecycle transaction observe the committed winner.
|
||||||
|
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' AND u.registration_pending=0)`, organizationID, actorUserID, organizationID, actorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return organizations.ErrMembershipNotFound
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
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 errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", organizations.ErrMembershipNotFound
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if status != "active" && status != "suspended" {
|
||||||
|
return "", errors.New("authsqlite: stored membership status is invalid")
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireOwnerAuthorityForOwnerTarget(ctx context.Context, tx *sql.Tx, organizationID, actorUserID, targetUserID, ownerRole string) error {
|
||||||
|
targetIsOwner, err := hasDirectOwnerRole(ctx, tx, organizationID, targetUserID, ownerRole)
|
||||||
|
if err != nil || !targetIsOwner {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
actorIsOwner, err := hasDirectOwnerRole(ctx, tx, organizationID, actorUserID, ownerRole)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !actorIsOwner {
|
||||||
|
return organizations.ErrOwnerAuthority
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasDirectOwnerRole(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_bindings
|
||||||
|
WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND role_name=?
|
||||||
|
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
|
||||||
|
AND revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&count); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) error {
|
||||||
|
var targetIsOwner int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_bindings WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND role_name=? AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL AND revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&targetIsOwner); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if targetIsOwner == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var otherActiveOwners 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' AND u.registration_pending=0
|
||||||
|
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`, organizationID, userID, ownerRole).Scan(&otherActiveOwners); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if otherActiveOwners == 0 {
|
||||||
|
return organizations.ErrLastOwner
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validMembershipStatusChange(input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) bool {
|
||||||
|
return opaqueID(input.OrganizationID) && opaqueID(input.UserID) && opaqueID(input.ActorUserID) && safeName(ownerRole) &&
|
||||||
|
(input.ExpectedStatus == "active" || input.ExpectedStatus == "suspended") &&
|
||||||
|
(input.Status == "active" || input.Status == "suspended") && input.ExpectedStatus != input.Status &&
|
||||||
|
validOrganizationAudit(audit, input.OrganizationID) && audit.ActorUserID == input.ActorUserID &&
|
||||||
|
audit.Action == "membership."+input.Status && audit.ResourceType == "membership" && audit.ResourceID == input.UserID && audit.RequestID == input.RequestID
|
||||||
|
}
|
||||||
|
|
||||||
|
func validMembershipRemoval(input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) bool {
|
||||||
|
return opaqueID(input.OrganizationID) && opaqueID(input.UserID) && opaqueID(input.ActorUserID) && safeName(ownerRole) &&
|
||||||
|
(input.ExpectedStatus == "active" || input.ExpectedStatus == "suspended") &&
|
||||||
|
validOrganizationAudit(audit, input.OrganizationID) && audit.ActorUserID == input.ActorUserID &&
|
||||||
|
audit.Action == "membership.remove" && audit.ResourceType == "membership" && audit.ResourceID == input.UserID && audit.RequestID == input.RequestID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Invitations(ctx context.Context, organizationID string, limit int) ([]organizations.Invitation, error) {
|
||||||
|
if !opaqueID(organizationID) || limit < 1 || limit > 1000 {
|
||||||
|
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,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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := make([]organizations.Invitation, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var value organizations.Invitation
|
||||||
|
var created, expires, used, revoked int64
|
||||||
|
var teamIDs, rolesJSON []byte
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if json.Unmarshal(teamIDs, &value.TeamIDs) != nil || !validInvitationTeamIDs(value.TeamIDs) {
|
||||||
|
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.CreatedAt, value.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
|
||||||
|
if used != 0 {
|
||||||
|
value.UsedAt = time.Unix(used, 0).UTC()
|
||||||
|
}
|
||||||
|
if revoked != 0 {
|
||||||
|
value.RevokedAt = time.Unix(revoked, 0).UTC()
|
||||||
|
}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validInvitationTeamIDs(teamIDs []string) bool {
|
||||||
|
if len(teamIDs) > 16 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(teamIDs))
|
||||||
|
for _, teamID := range teamIDs {
|
||||||
|
if !opaqueID(teamID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, exists := seen[teamID]; exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[teamID] = struct{}{}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
for _, teamID := range teamIDs {
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_teams WHERE id=? AND organization_id=? AND status='active'`, teamID, organizationID).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
return organizations.ErrTeamNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID, ownerRole string, revokedAt time.Time, audit organizations.AuditEvent) error {
|
||||||
|
if !opaqueID(organizationID) || !opaqueID(invitationID) || ownerRole != "" && !safeName(ownerRole) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
|
||||||
|
return organizations.ErrInvitationNotFound
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var invitation organizations.Invitation
|
||||||
|
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) {
|
||||||
|
return organizations.ErrInvitationNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !decodeInvitationRoles(&invitation, rolesJSON) {
|
||||||
|
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 {
|
||||||
|
return ownerErr
|
||||||
|
}
|
||||||
|
if !actorIsOwner {
|
||||||
|
return organizations.ErrOwnerAuthority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, revokedAt.Unix(), organizationID, invitationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return organizations.ErrInvitationNotFound
|
||||||
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validOrganization(value organizations.Organization) bool {
|
||||||
|
return opaqueID(value.ID) && slugValue(value.Slug) && text(value.Name, 128, false) && (value.Status == "active" || value.Status == "archived") && value.Revision > 0 && !value.CreatedAt.IsZero() && !value.UpdatedAt.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTeam(value organizations.Team) bool {
|
||||||
|
return opaqueID(value.ID) && opaqueID(value.OrganizationID) && slugValue(value.Slug) && text(value.Name, 128, false) && (value.Status == "active" || value.Status == "archived") && value.Revision > 0 && !value.CreatedAt.IsZero() && !value.UpdatedAt.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validOrganizationAudit(value organizations.AuditEvent, organizationID string) bool {
|
||||||
|
return opaqueID(value.ID) && opaqueID(organizationID) && value.OrganizationID == organizationID && opaqueID(value.ActorUserID) && text(value.Action, 128, false) && text(value.ResourceType, 128, false) && text(value.ResourceID, 128, false) && text(value.RequestID, 128, true) && text(value.Summary, 512, false) && !value.CreatedAt.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendOrganizationAudit(ctx context.Context, tx *sql.Tx, value organizations.AuditEvent) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_audit_events(id,organization_id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,?,?,?,?,?,NULLIF(?,''),?,?)`, value.ID, value.OrganizationID, value.ActorUserID, value.Action, value.ResourceType, value.ResourceID, value.RequestID, value.Summary, value.CreatedAt.Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func slugValue(value string) bool {
|
func slugValue(value string) bool {
|
||||||
if len(value) < 2 || len(value) > 63 || (value[0] < 'a' || value[0] > 'z') && (value[0] < '0' || value[0] > '9') {
|
if len(value) < 2 || len(value) > 63 || (value[0] < 'a' || value[0] > 'z') && (value[0] < '0' || value[0] > '9') {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -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,72 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gamertan.com/web/organizations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateOwnedOrganization atomically creates a new organization and its first
|
||||||
|
// direct owner. It never grants authority in an existing organization.
|
||||||
|
func (store *Store) CreateOwnedOrganization(ctx context.Context, setup organizations.OwnedOrganization) error {
|
||||||
|
organization, membership, binding := setup.Organization, setup.Membership, setup.OwnerBinding
|
||||||
|
audit, accessAudit := setup.OrganizationAudit, setup.AccessAudit
|
||||||
|
if !validOrganization(organization) || organization.Status != "active" || organization.Revision != 1 ||
|
||||||
|
membership.OrganizationID != organization.ID || !opaqueID(membership.UserID) || membership.Status != "active" || !membership.JoinedAt.Equal(organization.CreatedAt) ||
|
||||||
|
!validOwnerBinding(binding, organization.ID, membership.UserID) || !binding.GrantedAt.Equal(organization.CreatedAt) ||
|
||||||
|
!validOrganizationAudit(audit, organization.ID) || audit.ActorUserID != membership.UserID || audit.Action != "organization.create" || audit.ResourceType != "organization" || audit.ResourceID != organization.ID ||
|
||||||
|
!validAccessAudit(accessAudit) || accessAudit.OrganizationID != organization.ID || accessAudit.ActorUserID != membership.UserID || accessAudit.Action != "access.binding.grant" || accessAudit.ResourceType != "binding" || accessAudit.ResourceID != binding.ID || accessAudit.RequestID != audit.RequestID {
|
||||||
|
return errors.New("authsqlite: invalid owned organization")
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var personalOwner any
|
||||||
|
if organization.Personal {
|
||||||
|
personalOwner = membership.UserID
|
||||||
|
}
|
||||||
|
// The first statement acquires the writer lock and validates active, completed
|
||||||
|
// identity inside the transaction; account suspension cannot race the grant.
|
||||||
|
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at)
|
||||||
|
SELECT ?,?,?,?,?,?,?,?,? FROM gwf_users WHERE id=? AND status='active' AND registration_pending=0`,
|
||||||
|
organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner,
|
||||||
|
organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix(), membership.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, err := result.RowsAffected(); err != nil || changed != 1 {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return organizations.ErrOwnerAuthority
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, organization.ID, membership.UserID, membership.Status, membership.JoinedAt.Unix()); 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=?`,
|
||||||
|
binding.ID, organization.ID, membership.UserID, binding.Role, membership.UserID, binding.GrantedAt.Unix(), binding.Role)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, err := result.RowsAffected(); err != nil || changed != 1 {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errors.New("authsqlite: initial owner role has not been seeded")
|
||||||
|
}
|
||||||
|
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAccessAudit(ctx, tx, accessAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ organizations.OwnedOrganizationRepository = (*Store)(nil)
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
"gamertan.com/web/organizations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ownedOrganizationFixture(t *testing.T) (*Store, *organizations.Service, access.Policy, organizations.CreateOrganization) {
|
||||||
|
t.Helper()
|
||||||
|
store, err := Open(filepath.Join(t.TempDir(), "owned.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
now := time.Unix(2000, 0).UTC()
|
||||||
|
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('customer-12345','customer','customer','customer@example.test','customer@example.test','Customer','active',0,2000,2000)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
policy := access.Policy{
|
||||||
|
Roles: map[string]string{"customer.owner": "Customer owner", "home.owner": "Merchant owner"},
|
||||||
|
Permissions: map[string]string{"customer.purchase": "Purchase", "merchant.manage": "Manage merchant"},
|
||||||
|
Grants: map[string][]string{"customer.owner": {"customer.purchase"}, "home.owner": {"merchant.manage"}},
|
||||||
|
}
|
||||||
|
accessService, err := access.New(store, policy, access.Options{})
|
||||||
|
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", Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return store, service, policy, organizations.CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345", RequestID: "request-creation"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countOwnedRows(t *testing.T, store *Store, want int) {
|
||||||
|
t.Helper()
|
||||||
|
for _, table := range []string{"gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_access_audit_events"} {
|
||||||
|
var count int
|
||||||
|
if err := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expected := want
|
||||||
|
if table == "gwf_access_audit_events" {
|
||||||
|
expected *= 2
|
||||||
|
}
|
||||||
|
if count != expected {
|
||||||
|
t.Errorf("%s count=%d want=%d", table, count, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedOrganizationCommitsScopedOwnerAndAudits(t *testing.T) {
|
||||||
|
store, service, policy, input := ownedOrganizationFixture(t)
|
||||||
|
organization, err := service.CreateOwnedOrganization(t.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
countOwnedRows(t, store, 1)
|
||||||
|
accessService, err := access.New(store, policy, access.Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
scope string
|
||||||
|
permission string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{organization.ID, "customer.purchase", true},
|
||||||
|
{organization.ID, "merchant.manage", false},
|
||||||
|
{"other-org-12345", "customer.purchase", false},
|
||||||
|
} {
|
||||||
|
decision, err := accessService.Authorize(t.Context(), input.OwnerUserID, access.Scope{OrganizationID: test.scope}, test.permission)
|
||||||
|
if err != nil || decision.Allowed != test.want {
|
||||||
|
t.Fatalf("scope=%s permission=%s decision=%+v err=%v", test.scope, test.permission, decision, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, action := range []string{"organization.create", "access.binding.grant"} {
|
||||||
|
var actor, request string
|
||||||
|
if err = store.db.QueryRow(`SELECT actor_user_id,request_id FROM gwf_access_audit_events WHERE organization_id=? AND action=?`, organization.ID, action).Scan(&actor, &request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if actor != input.OwnerUserID || request != input.RequestID {
|
||||||
|
t.Fatalf("audit actor=%q request=%q", actor, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = service.CreateOwnedOrganization(t.Context(), input); err == nil {
|
||||||
|
t.Fatal("duplicate slug accepted")
|
||||||
|
}
|
||||||
|
countOwnedRows(t, store, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedOrganizationRollsBackEveryWriteFailure(t *testing.T) {
|
||||||
|
for _, stage := range []struct{ table, when string }{
|
||||||
|
{"gwf_organizations", ""}, {"gwf_organization_memberships", ""}, {"gwf_access_bindings", ""},
|
||||||
|
{"gwf_access_audit_events", " WHEN NEW.action='organization.create'"},
|
||||||
|
{"gwf_access_audit_events", " WHEN NEW.action='access.binding.grant'"},
|
||||||
|
} {
|
||||||
|
t.Run(stage.table+stage.when, func(t *testing.T) {
|
||||||
|
store, service, _, input := ownedOrganizationFixture(t)
|
||||||
|
if _, err := store.db.Exec(`CREATE TRIGGER reject_creation BEFORE INSERT ON ` + stage.table + stage.when + ` BEGIN SELECT RAISE(ABORT,'injected write failure'); END`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if organization, err := service.CreateOwnedOrganization(t.Context(), input); err == nil || organization.ID != "" {
|
||||||
|
t.Fatalf("organization=%+v err=%v", organization, err)
|
||||||
|
}
|
||||||
|
countOwnedRows(t, store, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedOrganizationRejectsMissingRoleAndUnavailableOwner(t *testing.T) {
|
||||||
|
for _, change := range []string{
|
||||||
|
`DELETE FROM gwf_access_role_permissions WHERE role_name='customer.owner'; DELETE FROM gwf_access_roles WHERE name='customer.owner'`,
|
||||||
|
`UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`,
|
||||||
|
`UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`,
|
||||||
|
`DELETE FROM gwf_users WHERE id='customer-12345'`,
|
||||||
|
} {
|
||||||
|
t.Run(change, func(t *testing.T) {
|
||||||
|
store, service, _, input := ownedOrganizationFixture(t)
|
||||||
|
if _, err := store.db.Exec(change); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if organization, err := service.CreateOwnedOrganization(t.Context(), input); err == nil || organization.ID != "" {
|
||||||
|
t.Fatalf("organization=%+v err=%v", organization, err)
|
||||||
|
}
|
||||||
|
countOwnedRows(t, store, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentOwnedOrganizationCreationHasOneCompleteWinner(t *testing.T) {
|
||||||
|
store, service, _, input := ownedOrganizationFixture(t)
|
||||||
|
var workers sync.WaitGroup
|
||||||
|
results := make(chan error, 8)
|
||||||
|
for range 8 {
|
||||||
|
workers.Go(func() { _, err := service.CreateOwnedOrganization(t.Context(), input); results <- err })
|
||||||
|
}
|
||||||
|
workers.Wait()
|
||||||
|
close(results)
|
||||||
|
winners := 0
|
||||||
|
for err := range results {
|
||||||
|
if err == nil {
|
||||||
|
winners++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if winners != 1 {
|
||||||
|
t.Fatalf("successful creations=%d", winners)
|
||||||
|
}
|
||||||
|
countOwnedRows(t, store, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyOrganizationCreationRemainsMembershipOnly(t *testing.T) {
|
||||||
|
store, service, _, input := ownedOrganizationFixture(t)
|
||||||
|
if _, err := service.CreateOrganization(t.Context(), input); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var bindings int
|
||||||
|
if err := store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_bindings`).Scan(&bindings); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bindings != 0 {
|
||||||
|
t.Fatal("legacy creation unexpectedly granted authority")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedOrganizationSurvivesReopenAndProtectsLastOwner(t *testing.T) {
|
||||||
|
store, service, _, input := ownedOrganizationFixture(t)
|
||||||
|
organization, err := service.CreateOwnedOrganization(t.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var sequence int
|
||||||
|
var name, path string
|
||||||
|
if err = store.db.QueryRow(`PRAGMA database_list`).Scan(&sequence, &name, &path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = store.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reopened, err := OpenWithOptions(path, OpenOptions{Migrate: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer reopened.Close()
|
||||||
|
if err = reopened.RequireCurrentSchema(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
countOwnedRows(t, reopened, 1)
|
||||||
|
service, err = organizations.New(reopened, organizations.Options{OwnerRole: "customer.owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = service.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{
|
||||||
|
OrganizationID: organization.ID, UserID: input.OwnerUserID, ActorUserID: input.OwnerUserID, ExpectedStatus: "active",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, organizations.ErrLastOwner) {
|
||||||
|
t.Fatalf("last owner removal: %v", err)
|
||||||
|
}
|
||||||
|
err = service.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{
|
||||||
|
OrganizationID: organization.ID, UserID: input.OwnerUserID, ActorUserID: input.OwnerUserID, ExpectedStatus: "active", Status: "suspended",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, organizations.ErrLastOwner) {
|
||||||
|
t.Fatalf("last owner suspension: %v", err)
|
||||||
|
}
|
||||||
|
countOwnedRows(t, reopened, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
type capturedOwnedStore struct {
|
||||||
|
*Store
|
||||||
|
setup organizations.OwnedOrganization
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *capturedOwnedStore) CreateOwnedOrganization(_ context.Context, setup organizations.OwnedOrganization) error {
|
||||||
|
store.setup = setup
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedOrganizationRejectsMismatchedAuthorityAndAudits(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
change func(*organizations.OwnedOrganization)
|
||||||
|
}{
|
||||||
|
{"foreign member", func(s *organizations.OwnedOrganization) { s.Membership.OrganizationID = "other-org-12345" }},
|
||||||
|
{"foreign owner", func(s *organizations.OwnedOrganization) { s.OwnerBinding.SubjectID = "other-user-12345" }},
|
||||||
|
{"foreign scope", func(s *organizations.OwnedOrganization) { s.OwnerBinding.Scope.OrganizationID = "other-org-12345" }},
|
||||||
|
{"narrow scope", func(s *organizations.OwnedOrganization) { s.OwnerBinding.Scope.ProjectID = "project-12345" }},
|
||||||
|
{"team owner", func(s *organizations.OwnedOrganization) { s.OwnerBinding.SubjectKind = access.Team }},
|
||||||
|
{"wrong audit actor", func(s *organizations.OwnedOrganization) { s.AccessAudit.ActorUserID = "other-user-12345" }},
|
||||||
|
{"wrong audit binding", func(s *organizations.OwnedOrganization) { s.AccessAudit.ResourceID = "other-binding-12345" }},
|
||||||
|
{"wrong creation resource", func(s *organizations.OwnedOrganization) { s.OrganizationAudit.ResourceID = "other-org-12345" }},
|
||||||
|
{"wrong request", func(s *organizations.OwnedOrganization) { s.AccessAudit.RequestID = "other-request" }},
|
||||||
|
{"archived organization", func(s *organizations.OwnedOrganization) { s.Organization.Status = "archived" }},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
store, _, _, input := ownedOrganizationFixture(t)
|
||||||
|
capture := &capturedOwnedStore{Store: store}
|
||||||
|
service, err := organizations.New(capture, organizations.Options{OwnerRole: "customer.owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = service.CreateOwnedOrganization(t.Context(), input); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
test.change(&capture.setup)
|
||||||
|
if err = store.CreateOwnedOrganization(t.Context(), capture.setup); err == nil {
|
||||||
|
t.Fatal("invalid creation accepted")
|
||||||
|
}
|
||||||
|
countOwnedRows(t, store, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
-11
@@ -29,7 +29,7 @@ func (store *Store) CreatePasskeyUser(ctx context.Context, user auth.User, enrol
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
|
if _, err = tx.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(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, user.RegistrationPending, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], enrollment.UserID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil {
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], enrollment.UserID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil {
|
||||||
@@ -45,7 +45,7 @@ func (store *Store) UserByID(ctx context.Context, userID string) (auth.User, err
|
|||||||
if !opaqueID(userID) {
|
if !opaqueID(userID) {
|
||||||
return auth.User{}, auth.ErrUserNotFound
|
return auth.User{}, auth.ErrUserNotFound
|
||||||
}
|
}
|
||||||
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
|
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (auth.User, error) {
|
func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (auth.User, error) {
|
||||||
@@ -53,14 +53,14 @@ func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (au
|
|||||||
if !text(identifier, 320, false) {
|
if !text(identifier, 320, false) {
|
||||||
return auth.User{}, auth.ErrUserNotFound
|
return auth.User{}, auth.ErrUserNotFound
|
||||||
}
|
}
|
||||||
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
|
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) UserByCredentialID(ctx context.Context, credentialID []byte) (auth.User, error) {
|
func (store *Store) UserByCredentialID(ctx context.Context, credentialID []byte) (auth.User, error) {
|
||||||
if !boundedCredentialID(credentialID) {
|
if !boundedCredentialID(credentialID) {
|
||||||
return auth.User{}, authwebauthn.ErrCredentialNotFound
|
return auth.User{}, authwebauthn.ErrCredentialNotFound
|
||||||
}
|
}
|
||||||
user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at FROM gwf_users u JOIN gwf_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID))
|
user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at FROM gwf_users u JOIN gwf_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID))
|
||||||
if errors.Is(err, auth.ErrUserNotFound) {
|
if errors.Is(err, auth.ErrUserNotFound) {
|
||||||
return auth.User{}, authwebauthn.ErrCredentialNotFound
|
return auth.User{}, authwebauthn.ErrCredentialNotFound
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,17 @@ func (store *Store) CredentialsByUserID(ctx context.Context, userID string) ([]a
|
|||||||
return credentials, rows.Err()
|
return credentials, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) PasswordCredentialExists(ctx context.Context, userID string) (bool, error) {
|
||||||
|
if !opaqueID(userID) {
|
||||||
|
return false, auth.ErrUserNotFound
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_password_credentials WHERE user_id=?`, userID).Scan(&count); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) SaveCredential(ctx context.Context, credential authwebauthn.Credential, audit auth.AuditEvent) error {
|
func (store *Store) SaveCredential(ctx context.Context, credential authwebauthn.Credential, audit auth.AuditEvent) error {
|
||||||
if !validCredential(credential, true) || !validAuditEvent(audit) {
|
if !validCredential(credential, true) || !validAuditEvent(audit) {
|
||||||
return errors.New("authsqlite: invalid passkey credential")
|
return errors.New("authsqlite: invalid passkey credential")
|
||||||
@@ -118,6 +129,56 @@ func (store *Store) SaveCredential(ctx context.Context, credential authwebauthn.
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) SaveCredentialAndRetirePassword(ctx context.Context, credential authwebauthn.Credential, audit auth.AuditEvent) error {
|
||||||
|
if !validCredential(credential, true) || !validAuditEvent(audit) || audit.ActorUserID != credential.UserID || audit.Action != "auth.passkey.migrate" {
|
||||||
|
return errors.New("authsqlite: invalid passkey migration")
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var credentialCount, passwordCount int
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, credential.UserID).Scan(&credentialCount); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if credentialCount >= maxPasskeysPerUser {
|
||||||
|
return errors.New("authsqlite: passkey credential limit reached")
|
||||||
|
}
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_password_credentials WHERE user_id=?`, credential.UserID).Scan(&passwordCount); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if passwordCount != 1 {
|
||||||
|
return authwebauthn.ErrPasswordNotAvailable
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, credential.UserID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_password_credentials WHERE user_id=?`, credential.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return authwebauthn.ErrPasswordNotAvailable
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, credential.CreatedAt.Unix(), credential.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, credential.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, credential.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`, credential.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) UpdateCredential(ctx context.Context, credential authwebauthn.Credential) error {
|
func (store *Store) UpdateCredential(ctx context.Context, credential authwebauthn.Credential) error {
|
||||||
if !validCredential(credential, false) || credential.LastUsedAt.IsZero() {
|
if !validCredential(credential, false) || credential.LastUsedAt.IsZero() {
|
||||||
return errors.New("authsqlite: invalid passkey credential update")
|
return errors.New("authsqlite: invalid passkey credential update")
|
||||||
@@ -230,7 +291,7 @@ func (store *Store) ConsumeEnrollmentToken(ctx context.Context, digest [32]byte,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return auth.User{}, err
|
return auth.User{}, err
|
||||||
}
|
}
|
||||||
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
|
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return auth.User{}, err
|
return auth.User{}, err
|
||||||
}
|
}
|
||||||
@@ -249,7 +310,7 @@ func (store *Store) RecoverUser(ctx context.Context, identifier string, enrollme
|
|||||||
return auth.User{}, err
|
return auth.User{}, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
|
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return auth.User{}, err
|
return auth.User{}, err
|
||||||
}
|
}
|
||||||
@@ -281,21 +342,22 @@ type rowScanner interface{ Scan(...any) error }
|
|||||||
|
|
||||||
func scanPasskeyUser(row rowScanner) (auth.User, error) {
|
func scanPasskeyUser(row rowScanner) (auth.User, error) {
|
||||||
var user auth.User
|
var user auth.User
|
||||||
var passwordChangeRequired int
|
var passwordChangeRequired, registrationPending int
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated); err != nil {
|
if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, ®istrationPending, &created, &updated); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return auth.User{}, auth.ErrUserNotFound
|
return auth.User{}, auth.ErrUserNotFound
|
||||||
}
|
}
|
||||||
return auth.User{}, err
|
return auth.User{}, err
|
||||||
}
|
}
|
||||||
user.PasswordChangeRequired = passwordChangeRequired == 1
|
user.PasswordChangeRequired = passwordChangeRequired == 1
|
||||||
|
user.RegistrationPending = registrationPending == 1
|
||||||
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validPasskeyUser(user auth.User) bool {
|
func validPasskeyUser(user auth.User) bool {
|
||||||
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
|
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.RegistrationPending && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
|
||||||
}
|
}
|
||||||
|
|
||||||
func validEnrollment(token authwebauthn.EnrollmentToken) bool {
|
func validEnrollment(token authwebauthn.EnrollmentToken) bool {
|
||||||
@@ -311,9 +373,10 @@ func validCredential(credential authwebauthn.Credential, requireLabel bool) bool
|
|||||||
func validCeremony(ceremony authwebauthn.Ceremony) bool {
|
func validCeremony(ceremony authwebauthn.Ceremony) bool {
|
||||||
validKind := ceremony.Kind == authwebauthn.CeremonyRegistration || ceremony.Kind == authwebauthn.CeremonyLogin || ceremony.Kind == authwebauthn.CeremonyApproval
|
validKind := ceremony.Kind == authwebauthn.CeremonyRegistration || ceremony.Kind == authwebauthn.CeremonyLogin || ceremony.Kind == authwebauthn.CeremonyApproval
|
||||||
validUser := ceremony.Kind == authwebauthn.CeremonyLogin && ceremony.UserID == "" || opaqueID(ceremony.UserID)
|
validUser := ceremony.Kind == authwebauthn.CeremonyLogin && ceremony.UserID == "" || opaqueID(ceremony.UserID)
|
||||||
validLabel := ceremony.Kind == authwebauthn.CeremonyRegistration && text(ceremony.Label, 80, false) || ceremony.Kind != authwebauthn.CeremonyRegistration && ceremony.Label == ""
|
registrationKind := ceremony.Kind == authwebauthn.CeremonyRegistration
|
||||||
|
validLabel := registrationKind && text(ceremony.Label, 80, false) || !registrationKind && ceremony.Label == ""
|
||||||
zeroBinding := zeroDigest(ceremony.BindingDigest)
|
zeroBinding := zeroDigest(ceremony.BindingDigest)
|
||||||
validBinding := ceremony.Kind == authwebauthn.CeremonyApproval && !zeroBinding || ceremony.Kind != authwebauthn.CeremonyApproval && zeroBinding
|
validBinding := ceremony.Kind == authwebauthn.CeremonyApproval && !zeroBinding || ceremony.Kind == authwebauthn.CeremonyRegistration || ceremony.Kind == authwebauthn.CeremonyLogin && zeroBinding
|
||||||
return !zeroDigest(ceremony.Digest) && validKind && validUser && validLabel && validBinding && len(ceremony.SessionData) > 0 && len(ceremony.SessionData) <= maxCeremonySessionBytes && json.Valid(ceremony.SessionData) && !ceremony.CreatedAt.IsZero() && ceremony.ExpiresAt.After(ceremony.CreatedAt)
|
return !zeroDigest(ceremony.Digest) && validKind && validUser && validLabel && validBinding && len(ceremony.SessionData) > 0 && len(ceremony.SessionData) <= maxCeremonySessionBytes && json.Valid(ceremony.SessionData) && !ceremony.CreatedAt.IsZero() && ceremony.ExpiresAt.After(ceremony.CreatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,53 @@ func TestPasskeyCeremonyIsConsumedExactlyOnceConcurrently(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasskeyMigrationAtomicallyRetiresPasswordAndSessions(t *testing.T) {
|
||||||
|
store, err := Open(t.TempDir() + "/auth.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||||
|
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "migrate.me", Email: "migrate@example.test", DisplayName: "Migration Test", Password: "legacy password credential"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id := bytes.Repeat([]byte{7}, 32)
|
||||||
|
encoded, err := json.Marshal(wa.Credential{ID: id, PublicKey: []byte{1, 2, 3}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
credential := authwebauthn.Credential{ID: id, UserID: user.ID, Label: "Primary passkey", Data: encoded, CreatedAt: now}
|
||||||
|
audit := auth.AuditEvent{ID: "migration-audit", ActorUserID: user.ID, Action: "auth.passkey.migrate", ResourceType: "passkey", ResourceID: "credential", Summary: "migration", CreatedAt: now}
|
||||||
|
if err = store.SaveCredentialAndRetirePassword(t.Context(), credential, audit); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if exists, existsErr := store.PasswordCredentialExists(t.Context(), user.ID); existsErr != nil || exists {
|
||||||
|
t.Fatalf("password exists=%v err=%v", exists, existsErr)
|
||||||
|
}
|
||||||
|
if _, _, err = authService.Authenticate(t.Context(), user.Username, "legacy password credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("legacy password still authenticates: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = authService.Session(t.Context(), session); !errors.Is(err, auth.ErrSessionNotFound) {
|
||||||
|
t.Fatalf("session survived migration: %v", err)
|
||||||
|
}
|
||||||
|
credentials, err := store.CredentialsByUserID(t.Context(), user.ID)
|
||||||
|
if err != nil || len(credentials) != 1 || !bytes.Equal(credentials[0].ID, id) {
|
||||||
|
t.Fatalf("credentials=%+v err=%v", credentials, err)
|
||||||
|
}
|
||||||
|
if err = store.SaveCredentialAndRetirePassword(t.Context(), credential, audit); !errors.Is(err, authwebauthn.ErrPasswordNotAvailable) {
|
||||||
|
t.Fatalf("migration replay err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testAudit(id, action, resourceID string, now time.Time) auth.AuditEvent {
|
func testAudit(id, action, resourceID string, now time.Time) auth.AuditEvent {
|
||||||
return auth.AuditEvent{ID: id, Action: action, ResourceType: "user", ResourceID: resourceID, Summary: "test", CreatedAt: now}
|
return auth.AuditEvent{ID: id, Action: action, ResourceType: "user", ResourceID: resourceID, Summary: "test", CreatedAt: now}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/authrecovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (store *Store) ReplaceRecoveryCodes(ctx context.Context, userID string, digests [][32]byte, createdAt time.Time, audit auth.AuditEvent) error {
|
||||||
|
if !opaqueID(userID) || len(digests) < 5 || len(digests) > 20 || createdAt.IsZero() || !validAuditEvent(audit) || audit.ResourceID != userID {
|
||||||
|
return errors.New("authsqlite: invalid recovery-code set")
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, digest := range digests {
|
||||||
|
if zeroDigest(digest) {
|
||||||
|
return errors.New("authsqlite: invalid recovery-code digest")
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at) VALUES(?,?,?)`, userID, digest[:], createdAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) RecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
|
||||||
|
if zeroDigest(digest) || now.IsZero() {
|
||||||
|
return auth.User{}, authrecovery.ErrGrantNotFound
|
||||||
|
}
|
||||||
|
user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at FROM gwf_recovery_grants g JOIN gwf_users u ON u.id=g.user_id WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()))
|
||||||
|
if errors.Is(err, auth.ErrUserNotFound) {
|
||||||
|
return auth.User{}, authrecovery.ErrGrantNotFound
|
||||||
|
}
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CompletePasskeyRecovery(ctx context.Context, completion authrecovery.PasskeyCompletion) error {
|
||||||
|
credential := completion.Credential
|
||||||
|
credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID)
|
||||||
|
if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || !validAuditEvent(completion.RecoveryAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID {
|
||||||
|
return errors.New("authsqlite: invalid passkey recovery completion")
|
||||||
|
}
|
||||||
|
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
|
||||||
|
for _, digest := range completion.RecoveryDigests {
|
||||||
|
if zeroDigest(digest) {
|
||||||
|
return errors.New("authsqlite: invalid recovery-code digest")
|
||||||
|
}
|
||||||
|
if _, exists := seen[digest]; exists {
|
||||||
|
return errors.New("authsqlite: duplicate recovery-code digest")
|
||||||
|
}
|
||||||
|
seen[digest] = struct{}{}
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var userID string
|
||||||
|
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return authrecovery.ErrGrantNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if userID != credential.UserID {
|
||||||
|
return errors.New("authsqlite: passkey recovery identity mismatch")
|
||||||
|
}
|
||||||
|
var active, pending int
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT status='active',registration_pending FROM gwf_users WHERE id=?`, userID).Scan(&active, &pending); err != nil || active != 1 || pending != 0 {
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return auth.ErrInactiveUser
|
||||||
|
}
|
||||||
|
existing, err := tx.QueryContext(ctx, `SELECT credential_id FROM gwf_passkey_credentials WHERE user_id=?`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for existing.Next() {
|
||||||
|
var id []byte
|
||||||
|
if err = existing.Scan(&id); err != nil {
|
||||||
|
existing.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if bytes.Equal(id, credential.ID) {
|
||||||
|
existing.Close()
|
||||||
|
return errors.New("authsqlite: passkey credential already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = existing.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, userID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, digest := range completion.RecoveryDigests {
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, digest[:], completion.CompletedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, completion.PasskeyAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, completion.RecoveryAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ConsumeRecoveryCodeAndCreateGrant(ctx context.Context, userID string, codeDigest [32]byte, grant authrecovery.Grant, audit auth.AuditEvent) error {
|
||||||
|
if !opaqueID(userID) || zeroDigest(codeDigest) || grant.UserID != userID || zeroDigest(grant.Digest) || grant.CreatedAt.IsZero() || !grant.ExpiresAt.After(grant.CreatedAt) || grant.ExpiresAt.Sub(grant.CreatedAt) > 30*time.Minute || !validAuditEvent(audit) || audit.ResourceID != userID {
|
||||||
|
return errors.New("authsqlite: invalid recovery attempt")
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_recovery_codes SET used_at=? WHERE user_id=? AND code_hash=? AND used_at IS NULL`, grant.CreatedAt.Unix(), userID, codeDigest[:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed, err := result.RowsAffected()
|
||||||
|
if err != nil || changed != 1 {
|
||||||
|
return authrecovery.ErrCodeNotFound
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_grants WHERE user_id=?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_grants(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, grant.Digest[:], userID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) TakeRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
|
||||||
|
if zeroDigest(digest) || now.IsZero() {
|
||||||
|
return auth.User{}, authrecovery.ErrGrantNotFound
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var userID string
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT user_id FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>?`, digest[:], now.Unix()).Scan(&userID); errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return auth.User{}, authrecovery.ErrGrantNotFound
|
||||||
|
} else if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=?`, digest[:]); err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
// 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;
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
-12
@@ -24,6 +24,17 @@ import (
|
|||||||
type Store struct{ db *sql.DB }
|
type Store struct{ db *sql.DB }
|
||||||
|
|
||||||
func Open(path string) (*Store, error) {
|
func Open(path string) (*Store, error) {
|
||||||
|
return OpenWithOptions(path, OpenOptions{Migrate: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenOptions struct {
|
||||||
|
// Migrate preserves the historical Open behavior when true. Applications
|
||||||
|
// with operator-controlled releases set it false and call Migrate only from
|
||||||
|
// their explicit migration command.
|
||||||
|
Migrate bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
|
||||||
absolute, err := filepath.Abs(path)
|
absolute, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -56,7 +67,7 @@ func Open(path string) (*Store, error) {
|
|||||||
store := &Store{db: db}
|
store := &Store{db: db}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err = db.PingContext(ctx); err == nil {
|
if err = db.PingContext(ctx); err == nil && options.Migrate {
|
||||||
err = store.Migrate(ctx)
|
err = store.Migrate(ctx)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -66,6 +77,34 @@ func Open(path string) (*Store, error) {
|
|||||||
return store, nil
|
return store, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SchemaVersion = 10
|
||||||
|
|
||||||
|
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
|
||||||
|
var exists int
|
||||||
|
if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='gamertan_web_migrations'`).Scan(&exists); err != nil || exists == 0 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var version sql.NullInt64
|
||||||
|
if err := store.db.QueryRowContext(ctx, `SELECT MAX(version) FROM gamertan_web_migrations`).Scan(&version); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if !version.Valid {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return int(version.Int64), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) RequireCurrentSchema(ctx context.Context) error {
|
||||||
|
version, err := store.CurrentSchema(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if version != SchemaVersion {
|
||||||
|
return fmt.Errorf("authsqlite: schema version %d; run migration for version %d", version, SchemaVersion)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) Close() error { return store.db.Close() }
|
func (store *Store) Close() error { return store.db.Close() }
|
||||||
func (store *Store) Ping(ctx context.Context) error { return store.db.PingContext(ctx) }
|
func (store *Store) Ping(ctx context.Context) error { return store.db.PingContext(ctx) }
|
||||||
|
|
||||||
@@ -77,7 +116,7 @@ func (store *Store) Migrate(ctx context.Context) error {
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
statements := []string{
|
statements := []string{
|
||||||
`CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`,
|
`CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`,
|
`CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1)), registration_pending INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`,
|
`CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
|
`CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
|
`CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
|
||||||
@@ -94,16 +133,23 @@ func (store *Store) Migrate(ctx context.Context) error {
|
|||||||
`CREATE INDEX IF NOT EXISTS gwf_passkey_enrollment_expiry ON gwf_passkey_enrollment_tokens(expires_at)`,
|
`CREATE INDEX IF NOT EXISTS gwf_passkey_enrollment_expiry ON gwf_passkey_enrollment_tokens(expires_at)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_passkey_ceremonies (token_hash BLOB PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('registration','login','approval')), user_id TEXT REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, session_json BLOB NOT NULL, binding_hash BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
`CREATE TABLE IF NOT EXISTS gwf_passkey_ceremonies (token_hash BLOB PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('registration','login','approval')), user_id TEXT REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, session_json BLOB NOT NULL, binding_hash BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
||||||
`CREATE INDEX IF NOT EXISTS gwf_passkey_ceremonies_expiry ON gwf_passkey_ceremonies(expires_at)`,
|
`CREATE INDEX IF NOT EXISTS gwf_passkey_ceremonies_expiry ON gwf_passkey_ceremonies(expires_at)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL)`,
|
`CREATE TABLE IF NOT EXISTS gwf_recovery_codes (user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, code_hash BLOB NOT NULL, created_at INTEGER NOT NULL, used_at INTEGER, PRIMARY KEY(user_id,code_hash))`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS gwf_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS gwf_recovery_grants_expiry ON gwf_recovery_grants(expires_at)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS gwf_assisted_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, issued_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS gwf_assisted_recovery_grants_expiry ON gwf_assisted_recovery_grants(expires_at)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS gwf_account_registrations (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS gwf_account_registrations_expiry ON gwf_account_registrations(expires_at)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_organization_memberships (organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK(status IN ('active','suspended')), joined_at INTEGER NOT NULL, PRIMARY KEY(organization_id,user_id))`,
|
`CREATE TABLE IF NOT EXISTS gwf_organization_memberships (organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK(status IN ('active','suspended')), joined_at INTEGER NOT NULL, PRIMARY KEY(organization_id,user_id))`,
|
||||||
`CREATE INDEX IF NOT EXISTS gwf_organization_memberships_user ON gwf_organization_memberships(user_id,organization_id)`,
|
`CREATE INDEX IF NOT EXISTS gwf_organization_memberships_user ON gwf_organization_memberships(user_id,organization_id)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_teams (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(organization_id,slug))`,
|
`CREATE TABLE IF NOT EXISTS gwf_teams (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(organization_id,slug))`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_team_members (team_id TEXT NOT NULL REFERENCES gwf_teams(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, joined_at INTEGER NOT NULL, PRIMARY KEY(team_id,user_id))`,
|
`CREATE TABLE IF NOT EXISTS gwf_team_members (team_id TEXT NOT NULL REFERENCES gwf_teams(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, joined_at INTEGER NOT NULL, PRIMARY KEY(team_id,user_id))`,
|
||||||
`CREATE INDEX IF NOT EXISTS gwf_team_members_user ON gwf_team_members(user_id,team_id)`,
|
`CREATE INDEX IF NOT EXISTS gwf_team_members_user ON gwf_team_members(user_id,team_id)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_projects (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(organization_id,slug))`,
|
`CREATE TABLE IF NOT EXISTS gwf_projects (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(organization_id,slug))`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_environments (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES gwf_projects(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(project_id,slug))`,
|
`CREATE TABLE IF NOT EXISTS gwf_environments (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES gwf_projects(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(project_id,slug))`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_application_services (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES gwf_projects(id) ON DELETE CASCADE, environment_id TEXT NOT NULL REFERENCES gwf_environments(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(environment_id,slug))`,
|
`CREATE TABLE IF NOT EXISTS gwf_application_services (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES gwf_projects(id) ON DELETE CASCADE, environment_id TEXT NOT NULL REFERENCES gwf_environments(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(environment_id,slug))`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_organization_invitations (token_hash BLOB PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, email_normalized TEXT NOT NULL, invited_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER)`,
|
`CREATE TABLE IF NOT EXISTS gwf_organization_invitations (token_hash BLOB PRIMARY KEY, id TEXT NOT NULL UNIQUE, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, email_normalized TEXT NOT NULL, invited_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), direct_role TEXT NOT NULL DEFAULT '', team_ids_json BLOB NOT NULL DEFAULT '[]', created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER, revoked_at INTEGER)`,
|
||||||
`CREATE INDEX IF NOT EXISTS gwf_organization_invitations_expiry ON gwf_organization_invitations(expires_at)`,
|
`CREATE INDEX IF NOT EXISTS gwf_organization_invitations_expiry ON gwf_organization_invitations(expires_at)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_access_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
|
`CREATE TABLE IF NOT EXISTS gwf_access_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_access_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
|
`CREATE TABLE IF NOT EXISTS gwf_access_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
|
||||||
@@ -129,6 +175,45 @@ func (store *Store) Migrate(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, migration := range []struct {
|
||||||
|
table, column, definition string
|
||||||
|
}{
|
||||||
|
{"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`},
|
||||||
|
{"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", "updated_at", `INTEGER NOT NULL DEFAULT 0`},
|
||||||
|
{"gwf_teams", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`},
|
||||||
|
{"gwf_teams", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
|
||||||
|
{"gwf_teams", "updated_at", `INTEGER NOT NULL DEFAULT 0`},
|
||||||
|
{"gwf_organization_invitations", "id", `TEXT`},
|
||||||
|
{"gwf_organization_invitations", "revoked_at", `INTEGER`},
|
||||||
|
{"gwf_organization_invitations", "direct_role", `TEXT 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)
|
||||||
|
if columnErr != nil {
|
||||||
|
return columnErr
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
if _, err = tx.ExecContext(ctx, `ALTER TABLE `+migration.table+` ADD COLUMN `+migration.column+` `+migration.definition); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organizations SET updated_at=created_at WHERE updated_at=0`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_teams SET updated_at=created_at WHERE updated_at=0`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET id=lower(hex(token_hash)) WHERE id IS NULL`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS gwf_organization_invitations_id ON gwf_organization_invitations(id)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(1,?)`, time.Now().UTC().Unix()); err != nil {
|
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(1,?)`, time.Now().UTC().Unix()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -141,6 +226,24 @@ func (store *Store) Migrate(ctx context.Context) error {
|
|||||||
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(4,?)`, time.Now().UTC().Unix()); err != nil {
|
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(4,?)`, 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(5,?)`, time.Now().UTC().Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(6,?)`, time.Now().UTC().Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(7,?)`, time.Now().UTC().Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(8,?)`, time.Now().UTC().Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +276,7 @@ func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.PasswordChangeRequired, user.CreatedAt.Unix(), user.UpdatedAt.Unix())
|
_, err = tx.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(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.PasswordChangeRequired, user.RegistrationPending, user.CreatedAt.Unix(), user.UpdatedAt.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -189,9 +292,9 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
|
|||||||
}
|
}
|
||||||
var user auth.User
|
var user auth.User
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
var passwordChangeRequired int
|
var passwordChangeRequired, registrationPending int
|
||||||
var hash string
|
var hash string
|
||||||
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
|
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, ®istrationPending, &created, &updated, &hash)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return auth.User{}, "", auth.ErrUserNotFound
|
return auth.User{}, "", auth.ErrUserNotFound
|
||||||
}
|
}
|
||||||
@@ -199,6 +302,7 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
|
|||||||
return auth.User{}, "", err
|
return auth.User{}, "", err
|
||||||
}
|
}
|
||||||
user.PasswordChangeRequired = passwordChangeRequired == 1
|
user.PasswordChangeRequired = passwordChangeRequired == 1
|
||||||
|
user.RegistrationPending = registrationPending == 1
|
||||||
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||||
return user, hash, nil
|
return user, hash, nil
|
||||||
}
|
}
|
||||||
@@ -209,9 +313,9 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth
|
|||||||
}
|
}
|
||||||
var user auth.User
|
var user auth.User
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
var passwordChangeRequired int
|
var passwordChangeRequired, registrationPending int
|
||||||
var hash string
|
var hash string
|
||||||
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.id=?`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
|
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.id=?`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, ®istrationPending, &created, &updated, &hash)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return auth.User{}, "", auth.ErrUserNotFound
|
return auth.User{}, "", auth.ErrUserNotFound
|
||||||
}
|
}
|
||||||
@@ -219,6 +323,7 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth
|
|||||||
return auth.User{}, "", err
|
return auth.User{}, "", err
|
||||||
}
|
}
|
||||||
user.PasswordChangeRequired = passwordChangeRequired == 1
|
user.PasswordChangeRequired = passwordChangeRequired == 1
|
||||||
|
user.RegistrationPending = registrationPending == 1
|
||||||
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||||
return user, hash, nil
|
return user, hash, nil
|
||||||
}
|
}
|
||||||
@@ -307,12 +412,13 @@ func (store *Store) PrincipalBySession(ctx context.Context, digest [32]byte, now
|
|||||||
var principal auth.Principal
|
var principal auth.Principal
|
||||||
var session auth.Session
|
var session auth.Session
|
||||||
var created, updated, sessionCreated, expires, lastSeen int64
|
var created, updated, sessionCreated, expires, lastSeen int64
|
||||||
var passwordChangeRequired int
|
var passwordChangeRequired, registrationPending int
|
||||||
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &passwordChangeRequired, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
|
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &passwordChangeRequired, ®istrationPending, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound
|
return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound
|
||||||
}
|
}
|
||||||
principal.User.PasswordChangeRequired = passwordChangeRequired == 1
|
principal.User.PasswordChangeRequired = passwordChangeRequired == 1
|
||||||
|
principal.User.RegistrationPending = registrationPending == 1
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return auth.Principal{}, auth.Session{}, err
|
return auth.Principal{}, auth.Session{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
+472
-4
@@ -17,6 +17,24 @@ import (
|
|||||||
"gamertan.com/web/organizations"
|
"gamertan.com/web/organizations"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestOpenCanRequireExplicitMigration(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "explicit.db")
|
||||||
|
store, err := OpenWithOptions(path, OpenOptions{Migrate: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
if err = store.RequireCurrentSchema(t.Context()); err == nil {
|
||||||
|
t.Fatal("unmigrated database reported current")
|
||||||
|
}
|
||||||
|
if err = store.Migrate(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = store.RequireCurrentSchema(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceRoundTripWithApplicationPolicy(t *testing.T) {
|
func TestServiceRoundTripWithApplicationPolicy(t *testing.T) {
|
||||||
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -319,11 +337,11 @@ func TestOrganizationTeamResourceAndScopedAccessRoundTrip(t *testing.T) {
|
|||||||
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
|
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators"})
|
team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators", ActorUserID: owner.ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err = organizationService.AddTeamMember(t.Context(), team.ID, member.ID); err != nil {
|
if err = organizationService.AddTeamMember(t.Context(), team.ID, member.ID, owner.ID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "eql", Name: "EQL"})
|
project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "eql", Name: "EQL"})
|
||||||
@@ -366,11 +384,461 @@ func TestOrganizationTeamResourceAndScopedAccessRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("break-glass decision=%+v err=%v", decision, err)
|
t.Fatalf("break-glass decision=%+v err=%v", decision, err)
|
||||||
}
|
}
|
||||||
var audits int
|
var audits int
|
||||||
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&audits); err != nil || audits != 1 {
|
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&audits); err != nil || audits != 6 {
|
||||||
t.Fatalf("audits=%d err=%v", audits, err)
|
t.Fatalf("audits=%d err=%v", audits, err)
|
||||||
}
|
}
|
||||||
auditEvents, err := accessService.Audit(t.Context(), organization.ID, 10)
|
auditEvents, err := accessService.Audit(t.Context(), organization.ID, 10)
|
||||||
if err != nil || len(auditEvents) != 1 || auditEvents[0].Action != "break_glass.activate" {
|
if err != nil || len(auditEvents) != 6 {
|
||||||
t.Fatalf("audit events=%+v err=%v", auditEvents, err)
|
t.Fatalf("audit events=%+v err=%v", auditEvents, err)
|
||||||
}
|
}
|
||||||
|
foundBreakGlass := false
|
||||||
|
for _, event := range auditEvents {
|
||||||
|
foundBreakGlass = foundBreakGlass || event.Action == "break_glass.activate"
|
||||||
|
}
|
||||||
|
if !foundBreakGlass {
|
||||||
|
t.Fatalf("break-glass audit missing: %+v", auditEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvitationAccessLifecycleAndLastOwnerProtection(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, 8, 27, 12, 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: "owner.lifecycle", Email: "owner-lifecycle@example.test", DisplayName: "Owner", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "member.lifecycle", Email: "member-lifecycle@example.test", DisplayName: "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: "organization.owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "lifecycle-test", Name: "Lifecycle Test", OwnerUserID: owner.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
policy := access.Policy{Roles: map[string]string{"organization.owner": "Owner"}, Permissions: map[string]string{"telemetry.read": "Read"}, Grants: map[string][]string{"organization.owner": {"telemetry.read"}}}
|
||||||
|
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = accessService.Seed(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "organization.owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: owner.ID, RequestID: "request-last-owner"}); !errors.Is(err, organizations.ErrLastOwner) {
|
||||||
|
t.Fatalf("last-owner suspension err=%v", err)
|
||||||
|
}
|
||||||
|
team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators", ActorUserID: owner.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, invitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: member.Email, InvitedByUserID: owner.ID, DirectRole: "organization.owner", TeamIDs: []string{team.ID}, Lifetime: 7 * 24 * time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if invitation.DirectRole != "organization.owner" || len(invitation.TeamIDs) != 1 {
|
||||||
|
t.Fatalf("invitation=%+v", invitation)
|
||||||
|
}
|
||||||
|
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decision, err := accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read")
|
||||||
|
if err != nil || !decision.Allowed {
|
||||||
|
t.Fatalf("member decision=%+v err=%v", decision, err)
|
||||||
|
}
|
||||||
|
teams, err := organizationService.Teams(t.Context(), organization.ID, member.ID)
|
||||||
|
if err != nil || len(teams) != 1 || teams[0].ID != team.ID {
|
||||||
|
t.Fatalf("member teams=%+v err=%v", teams, err)
|
||||||
|
}
|
||||||
|
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: owner.ID, RequestID: "request-suspend-owner"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", ActorUserID: member.ID, RequestID: "request-last-member"}); !errors.Is(err, organizations.ErrLastOwner) {
|
||||||
|
t.Fatalf("sole active owner removal err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = organizationService.SetOrganizationStatus(t.Context(), organizations.SetOrganizationStatus{ID: organization.ID, Status: "archived", ActorUserID: member.ID, ExpectedRevision: organization.Revision, RequestID: "request-archive"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decision, err = accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read")
|
||||||
|
if err != nil || decision.Allowed {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: member.ID, RequestID: "request-self-promote", ExpectedBindingIDs: []string{memberBinding.ID}}); !errors.Is(err, access.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("non-owner self-promotion err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-demote-owner", ExpectedBindingIDs: []string{ownerBinding.ID}}); !errors.Is(err, access.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("non-owner owner-demotion err=%v", err)
|
||||||
|
}
|
||||||
|
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: member.ID, RequestID: "request-suspend-owner-without-authority"}); !errors.Is(err, organizations.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("non-owner owner-suspension err=%v", err)
|
||||||
|
}
|
||||||
|
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", ActorUserID: member.ID, RequestID: "request-remove-owner-without-authority"}); !errors.Is(err, organizations.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("non-owner owner-removal err=%v", err)
|
||||||
|
}
|
||||||
|
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", member.ID, "request-legacy-suspend-owner-without-authority"); !errors.Is(err, organizations.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("legacy non-owner owner-suspension err=%v", err)
|
||||||
|
}
|
||||||
|
if err = organizationService.RemoveMembership(t.Context(), organization.ID, owner.ID, member.ID, "request-legacy-remove-owner-without-authority"); !errors.Is(err, organizations.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("legacy non-owner owner-removal err=%v", 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 TestOwnerInvitationsRequireDirectOwnerAuthority(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, 4, 12, 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: "invitation.owner", Email: "invitation-owner@example.test", DisplayName: "Invitation Owner", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manager, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "invitation.manager", Email: "invitation-manager@example.test", DisplayName: "Invitation Manager", 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: "invitation-authority", Name: "Invitation Authority", OwnerUserID: owner.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _, err := organizationService.Invite(t.Context(), organization.ID, manager.Email, owner.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = organizationService.AcceptInvitation(t.Context(), raw, manager.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
policy := access.Policy{
|
||||||
|
Roles: map[string]string{"owner": "Owner", "site-admin": "Site administrator", "viewer": "Viewer"},
|
||||||
|
Permissions: map[string]string{"site.access.manage": "Manage site access"},
|
||||||
|
Grants: map[string][]string{"owner": {"site.access.manage"}, "site-admin": {"site.access.manage"}, "viewer": {}},
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: manager.ID, Role: "site-admin", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err = organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "blocked-owner@example.test", InvitedByUserID: manager.ID, DirectRole: "owner", Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("non-owner owner invitation err=%v", err)
|
||||||
|
}
|
||||||
|
_, viewerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "viewer@example.test", InvitedByUserID: manager.ID, DirectRole: "viewer", Lifetime: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("non-owner ordinary invitation err=%v", err)
|
||||||
|
}
|
||||||
|
_, ownerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "new-owner@example.test", InvitedByUserID: owner.ID, DirectRole: "owner", Lifetime: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("owner invitation err=%v", err)
|
||||||
|
}
|
||||||
|
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, manager.ID, "request-manager-owner-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) {
|
||||||
|
t.Fatalf("non-owner owner invitation revocation err=%v", err)
|
||||||
|
}
|
||||||
|
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, viewerInvitation.ID, manager.ID, "request-manager-viewer-revoke"); err != nil {
|
||||||
|
t.Fatalf("ordinary invitation revocation err=%v", err)
|
||||||
|
}
|
||||||
|
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, owner.ID, "request-owner-owner-revoke"); err != nil {
|
||||||
|
t.Fatalf("owner invitation revocation err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptimisticMembershipLifecycleIsSerializedAndAtomic(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, 4, 9, 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: "lifecycle.owner", Email: "lifecycle-owner@example.test", DisplayName: "Lifecycle Owner", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "lifecycle.member", Email: "lifecycle-member@example.test", DisplayName: "Lifecycle 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: "optimistic-lifecycle", Name: "Optimistic Lifecycle", OwnerUserID: owner.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
policy := access.Policy{Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"}, Permissions: map[string]string{"telemetry.read": "Read"}, Grants: map[string][]string{"owner": {"telemetry.read"}, "viewer": {"telemetry.read"}}}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators", ActorUserID: owner.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: member.Email, InvitedByUserID: owner.ID, DirectRole: "viewer", TeamIDs: []string{team.ID}, Lifetime: 24 * time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
results := make(chan error, 2)
|
||||||
|
for _, requestID := range []string{"request-suspend-one", "request-suspend-two"} {
|
||||||
|
requestID := requestID
|
||||||
|
go func() {
|
||||||
|
<-start
|
||||||
|
results <- organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: owner.ID, RequestID: requestID})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
var successful, conflicted int
|
||||||
|
for range 2 {
|
||||||
|
switch lifecycleErr := <-results; {
|
||||||
|
case lifecycleErr == nil:
|
||||||
|
successful++
|
||||||
|
case errors.Is(lifecycleErr, organizations.ErrRevisionConflict):
|
||||||
|
conflicted++
|
||||||
|
default:
|
||||||
|
t.Fatalf("concurrent membership suspension err=%v", lifecycleErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if successful != 1 || conflicted != 1 {
|
||||||
|
t.Fatalf("concurrent membership suspension success=%d conflict=%d", successful, conflicted)
|
||||||
|
}
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action='membership.suspended' AND resource_id=?`, member.ID, 1)
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, member.ID, 0)
|
||||||
|
decision, err := accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read")
|
||||||
|
if err != nil || decision.Allowed {
|
||||||
|
t.Fatalf("suspended member decision=%+v err=%v", decision, err)
|
||||||
|
}
|
||||||
|
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", ActorUserID: owner.ID, RequestID: "request-stale-remove"}); !errors.Is(err, organizations.ErrRevisionConflict) {
|
||||||
|
t.Fatalf("stale membership removal err=%v", err)
|
||||||
|
}
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE request_id=?`, "request-stale-remove", 0)
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, member.ID, 1)
|
||||||
|
|
||||||
|
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "suspended", Status: "active", ActorUserID: owner.ID, RequestID: "request-reactivate"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, member.ID, 0)
|
||||||
|
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "suspended", Status: "active", ActorUserID: owner.ID, RequestID: "request-stale-reactivate"}); !errors.Is(err, organizations.ErrRevisionConflict) {
|
||||||
|
t.Fatalf("stale membership reactivation err=%v", err)
|
||||||
|
}
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE request_id=?`, "request-stale-reactivate", 0)
|
||||||
|
|
||||||
|
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", ActorUserID: owner.ID, RequestID: "request-remove-member"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, member.ID, 0)
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND revoked_at IS NOT NULL`, member.ID, 1)
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE request_id=?`, "request-remove-member", 1)
|
||||||
|
decision, err = accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read")
|
||||||
|
if err != nil || decision.Allowed {
|
||||||
|
t.Fatalf("removed member decision=%+v err=%v", decision, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func membershipPresent(values []organizations.Membership, userID, status string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value.UserID == userID && value.Status == status {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authwebauthn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
|
||||||
|
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
|
||||||
|
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnforceCredentialAlgorithmUsesVerifiedCOSEKey(t *testing.T) {
|
||||||
|
privateKey, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
publicKey := privateKey.PublicKey().Bytes()
|
||||||
|
encoded, err := webauthncbor.Marshal(map[int64]any{
|
||||||
|
1: int64(webauthncose.EllipticKey),
|
||||||
|
3: int64(webauthncose.AlgES256),
|
||||||
|
-1: int64(webauthncose.P256),
|
||||||
|
-2: publicKey[1:33],
|
||||||
|
-3: publicKey[33:65],
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
credential := &wa.Credential{
|
||||||
|
PublicKey: encoded,
|
||||||
|
// This value is absent when a standards-compliant client serializes the
|
||||||
|
// mandatory attestation object without optional response conveniences.
|
||||||
|
Attestation: wa.CredentialAttestation{PublicKeyAlgorithm: 0},
|
||||||
|
}
|
||||||
|
if err = enforceCredentialAlgorithm(credential); err != nil {
|
||||||
|
t.Fatalf("verified ES256 COSE key rejected when convenience value was absent: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnforceCredentialAlgorithmRejectsOtherOrInvalidKeys(t *testing.T) {
|
||||||
|
rsaKey, err := webauthncbor.Marshal(map[int64]any{
|
||||||
|
1: int64(webauthncose.RSAKey),
|
||||||
|
3: int64(webauthncose.AlgRS256),
|
||||||
|
-1: []byte{0xff},
|
||||||
|
-2: []byte{0x01, 0x00, 0x01},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, credential := range map[string]*wa.Credential{
|
||||||
|
"nil": nil,
|
||||||
|
"malformed": {PublicKey: []byte("not-cose")},
|
||||||
|
"rsa": {PublicKey: rsaKey},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if err := enforceCredentialAlgorithm(credential); !errors.Is(err, ErrUnsupportedCredential) {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
-20
@@ -12,8 +12,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,9 +38,14 @@ const (
|
|||||||
var accountNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
|
var accountNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
RPID string
|
RPID string
|
||||||
RPDisplayName string
|
RPDisplayName string
|
||||||
Origin 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
|
EnrollmentLifetime time.Duration
|
||||||
RegistrationTTL time.Duration
|
RegistrationTTL time.Duration
|
||||||
LoginTTL time.Duration
|
LoginTTL time.Duration
|
||||||
@@ -66,7 +73,7 @@ func New(repository Repository, authService *auth.Service, config Config) (*Serv
|
|||||||
if repository == nil || authService == nil {
|
if repository == nil || authService == nil {
|
||||||
return nil, errors.New("authwebauthn: repository and auth service are required")
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(config.RPDisplayName) == "" || len(config.RPDisplayName) > 80 {
|
if strings.TrimSpace(config.RPDisplayName) == "" || len(config.RPDisplayName) > 80 {
|
||||||
@@ -190,7 +197,7 @@ func (service *Service) BeginEnrollment(ctx context.Context, enrollmentToken, la
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return BeginResult{}, err
|
return BeginResult{}, err
|
||||||
}
|
}
|
||||||
return service.beginRegistration(ctx, user, label)
|
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) BeginRegistration(ctx context.Context, userID, label string) (BeginResult, error) {
|
func (service *Service) BeginRegistration(ctx context.Context, userID, label string) (BeginResult, error) {
|
||||||
@@ -198,15 +205,67 @@ func (service *Service) BeginRegistration(ctx context.Context, userID, label str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return BeginResult{}, err
|
return BeginResult{}, err
|
||||||
}
|
}
|
||||||
return service.beginRegistration(ctx, user, label)
|
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) beginRegistration(ctx context.Context, user auth.User, label string) (BeginResult, error) {
|
// BeginAccountRegistration starts the initial passkey ceremony for a pending
|
||||||
|
// account. Binding must identify the surrounding single-use registration
|
||||||
|
// draft; only its digest is retained in ceremony state.
|
||||||
|
func (service *Service) BeginAccountRegistration(ctx context.Context, userID, label string, binding []byte) (BeginResult, error) {
|
||||||
|
if len(binding) < 16 || len(binding) > 4096 {
|
||||||
|
return BeginResult{}, ErrOperationBinding
|
||||||
|
}
|
||||||
|
user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID))
|
||||||
|
if err != nil {
|
||||||
|
return BeginResult{}, err
|
||||||
|
}
|
||||||
|
if !user.RegistrationPending || user.Status != "active" {
|
||||||
|
return BeginResult{}, auth.ErrInactiveUser
|
||||||
|
}
|
||||||
|
return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginRecoveryRegistration starts a replacement-passkey ceremony bound to a
|
||||||
|
// short-lived recovery grant selected by the application. The grant itself is
|
||||||
|
// never persisted in ceremony state; only its digest is retained.
|
||||||
|
func (service *Service) BeginRecoveryRegistration(ctx context.Context, userID, label string, binding []byte) (BeginResult, error) {
|
||||||
|
if len(binding) < 16 || len(binding) > 4096 {
|
||||||
|
return BeginResult{}, ErrOperationBinding
|
||||||
|
}
|
||||||
|
user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID))
|
||||||
|
if err != nil {
|
||||||
|
return BeginResult{}, err
|
||||||
|
}
|
||||||
|
if user.RegistrationPending || user.Status != "active" {
|
||||||
|
return BeginResult{}, auth.ErrInactiveUser
|
||||||
|
}
|
||||||
|
return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginPasswordMigration starts registration for an already authenticated
|
||||||
|
// password-backed user. Completion atomically retires the password and revokes
|
||||||
|
// all sessions, including the session that authorized this ceremony.
|
||||||
|
func (service *Service) BeginPasswordMigration(ctx context.Context, userID, label string) (BeginResult, error) {
|
||||||
|
user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID))
|
||||||
|
if err != nil {
|
||||||
|
return BeginResult{}, err
|
||||||
|
}
|
||||||
|
exists, err := service.repository.PasswordCredentialExists(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return BeginResult{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return BeginResult{}, ErrPasswordNotAvailable
|
||||||
|
}
|
||||||
|
return service.beginRegistration(ctx, user, label, CeremonyRegistration, passwordMigrationBinding(user.ID), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) beginRegistration(ctx context.Context, user auth.User, label, kind string, binding [32]byte, allowPending bool) (BeginResult, error) {
|
||||||
label, err := credentialLabel(label)
|
label, err := credentialLabel(label)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BeginResult{}, err
|
return BeginResult{}, err
|
||||||
}
|
}
|
||||||
adapter, err := service.user(ctx, user)
|
adapter, err := service.user(ctx, user, allowPending)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BeginResult{}, err
|
return BeginResult{}, err
|
||||||
}
|
}
|
||||||
@@ -223,14 +282,76 @@ func (service *Service) beginRegistration(ctx context.Context, user auth.User, l
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return BeginResult{}, fmt.Errorf("authwebauthn: begin registration: %w", err)
|
return BeginResult{}, fmt.Errorf("authwebauthn: begin registration: %w", err)
|
||||||
}
|
}
|
||||||
return service.storeCeremony(ctx, CeremonyRegistration, user.ID, label, session, [32]byte{}, creation.Response, service.config.RegistrationTTL)
|
return service.storeCeremony(ctx, kind, user.ID, label, session, binding, creation.Response, service.config.RegistrationTTL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
|
func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
|
||||||
|
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", [32]byte{}, response, false, false, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishRegistrationForUser verifies an ordinary self-service enrollment only
|
||||||
|
// when the ceremony belongs to the authenticated user selected by the
|
||||||
|
// application. The ceremony is consumed on mismatch so a leaked token cannot
|
||||||
|
// be retried through another account session.
|
||||||
|
func (service *Service) FinishRegistrationForUser(ctx context.Context, ceremonyToken, expectedUserID string, response []byte) (Credential, error) {
|
||||||
|
expectedUserID = strings.TrimSpace(expectedUserID)
|
||||||
|
if expectedUserID == "" {
|
||||||
|
return Credential{}, ErrOperationBinding
|
||||||
|
}
|
||||||
|
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, expectedUserID, [32]byte{}, response, false, false, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishAccountRegistration verifies an initial credential and delegates its
|
||||||
|
// persistence to commit so user activation, personal organization creation,
|
||||||
|
// owner binding, recovery-code storage, and the passkey can share one
|
||||||
|
// transaction. A failed commit consumes the WebAuthn ceremony and leaves the
|
||||||
|
// bounded account draft eligible for a fresh ceremony.
|
||||||
|
func (service *Service) FinishAccountRegistration(ctx context.Context, ceremonyToken string, binding, response []byte, commit RegistrationCommit) (Credential, error) {
|
||||||
|
if len(binding) < 16 || len(binding) > 4096 || commit == nil {
|
||||||
|
return Credential{}, ErrOperationBinding
|
||||||
|
}
|
||||||
|
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, true, commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishRecoveryRegistration verifies a replacement passkey and delegates its
|
||||||
|
// persistence to commit so recovery-grant consumption, credential storage, and
|
||||||
|
// recovery-code replacement can share one transaction.
|
||||||
|
func (service *Service) FinishRecoveryRegistration(ctx context.Context, ceremonyToken string, binding, response []byte, commit RegistrationCommit) (Credential, error) {
|
||||||
|
if len(binding) < 16 || len(binding) > 4096 || commit == nil {
|
||||||
|
return Credential{}, ErrOperationBinding
|
||||||
|
}
|
||||||
|
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, false, func(commitContext context.Context, credential Credential, audit auth.AuditEvent) error {
|
||||||
|
audit.Action = "auth.recovery.passkey"
|
||||||
|
audit.Summary = "A replacement passkey was enrolled during account recovery."
|
||||||
|
return commit(commitContext, credential, audit)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishPasswordMigration verifies the new passkey and persists it together
|
||||||
|
// with password retirement and session revocation in one storage transaction.
|
||||||
|
func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
|
||||||
ceremony, err := service.takeCeremony(ctx, ceremonyToken, CeremonyRegistration)
|
ceremony, err := service.takeCeremony(ctx, ceremonyToken, CeremonyRegistration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Credential{}, err
|
return Credential{}, err
|
||||||
}
|
}
|
||||||
|
return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true, false, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind, expectedUserID string, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
|
||||||
|
ceremony, err := service.takeCeremony(ctx, ceremonyToken, kind)
|
||||||
|
if err != nil {
|
||||||
|
return Credential{}, err
|
||||||
|
}
|
||||||
|
if expectedUserID != "" && ceremony.UserID != expectedUserID {
|
||||||
|
return Credential{}, ErrOperationBinding
|
||||||
|
}
|
||||||
|
return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword, allowPending, commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony Ceremony, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
|
||||||
|
if ceremony.BindingDigest != expectedBinding {
|
||||||
|
return Credential{}, ErrOperationBinding
|
||||||
|
}
|
||||||
if len(response) == 0 || len(response) > maxResponseBytes {
|
if len(response) == 0 || len(response) > maxResponseBytes {
|
||||||
return Credential{}, errors.New("authwebauthn: registration response is invalid")
|
return Credential{}, errors.New("authwebauthn: registration response is invalid")
|
||||||
}
|
}
|
||||||
@@ -238,7 +359,7 @@ func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Credential{}, err
|
return Credential{}, err
|
||||||
}
|
}
|
||||||
adapter, err := service.user(ctx, user)
|
adapter, err := service.user(ctx, user, allowPending)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Credential{}, err
|
return Credential{}, err
|
||||||
}
|
}
|
||||||
@@ -254,7 +375,7 @@ func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err)
|
return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err)
|
||||||
}
|
}
|
||||||
if verified.Attestation.PublicKeyAlgorithm != int64(webauthncose.AlgES256) {
|
if err = enforceCredentialAlgorithm(verified); err != nil {
|
||||||
return Credential{}, ErrUnsupportedCredential
|
return Credential{}, ErrUnsupportedCredential
|
||||||
}
|
}
|
||||||
encoded, err := json.Marshal(verified)
|
encoded, err := json.Marshal(verified)
|
||||||
@@ -263,16 +384,49 @@ func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken st
|
|||||||
}
|
}
|
||||||
now := service.now().UTC()
|
now := service.now().UTC()
|
||||||
record := Credential{ID: append([]byte(nil), verified.ID...), UserID: user.ID, Label: ceremony.Label, Data: encoded, CreatedAt: now}
|
record := Credential{ID: append([]byte(nil), verified.ID...), UserID: user.ID, Label: ceremony.Label, Data: encoded, CreatedAt: now}
|
||||||
audit, err := service.audit(user.ID, "auth.passkey.add", "passkey", base64.RawURLEncoding.EncodeToString(verified.ID), "A passkey was enrolled.")
|
action, summary := "auth.passkey.add", "A passkey was enrolled."
|
||||||
|
if retirePassword {
|
||||||
|
action, summary = "auth.passkey.migrate", "A passkey was enrolled and the legacy password credential was retired."
|
||||||
|
}
|
||||||
|
audit, err := service.audit(user.ID, action, "passkey", base64.RawURLEncoding.EncodeToString(verified.ID), summary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Credential{}, err
|
return Credential{}, err
|
||||||
}
|
}
|
||||||
if err = service.repository.SaveCredential(ctx, record, audit); err != nil {
|
if retirePassword {
|
||||||
|
err = service.repository.SaveCredentialAndRetirePassword(ctx, record, audit)
|
||||||
|
} else if commit != nil {
|
||||||
|
audit.Action = "auth.account.passkey"
|
||||||
|
audit.Summary = "The initial account passkey was enrolled."
|
||||||
|
err = commit(ctx, record, audit)
|
||||||
|
} else {
|
||||||
|
err = service.repository.SaveCredential(ctx, record, audit)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return Credential{}, err
|
return Credential{}, err
|
||||||
}
|
}
|
||||||
return record, nil
|
return record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enforceCredentialAlgorithm derives the algorithm from the verified COSE key
|
||||||
|
// carried inside authenticator data. AuthenticatorAttestationResponse's
|
||||||
|
// publicKeyAlgorithm member is an optional browser convenience value: clients
|
||||||
|
// that serialize the mandatory attestation object directly may omit it, and it
|
||||||
|
// is not the cryptographically authoritative representation.
|
||||||
|
func enforceCredentialAlgorithm(credential *wa.Credential) error {
|
||||||
|
if credential == nil {
|
||||||
|
return ErrUnsupportedCredential
|
||||||
|
}
|
||||||
|
parsed, err := webauthncose.ParsePublicKey(credential.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return ErrUnsupportedCredential
|
||||||
|
}
|
||||||
|
key, ok := parsed.(webauthncose.EC2PublicKeyData)
|
||||||
|
if !ok || key.Algorithm != int64(webauthncose.AlgES256) {
|
||||||
|
return ErrUnsupportedCredential
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) {
|
func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) {
|
||||||
challenge, err := service.randomBytes(32)
|
challenge, err := service.randomBytes(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -310,7 +464,7 @@ func (service *Service) FinishLogin(ctx context.Context, ceremonyToken string, r
|
|||||||
if lookupErr != nil || account.ID != string(userHandle) {
|
if lookupErr != nil || account.ID != string(userHandle) {
|
||||||
return nil, ErrCredentialNotFound
|
return nil, ErrCredentialNotFound
|
||||||
}
|
}
|
||||||
loaded, lookupErr = service.user(ctx, account)
|
loaded, lookupErr = service.user(ctx, account, false)
|
||||||
return loaded, lookupErr
|
return loaded, lookupErr
|
||||||
}, session, parsed)
|
}, session, parsed)
|
||||||
if err != nil || loaded == nil || user == nil {
|
if err != nil || loaded == nil || user == nil {
|
||||||
@@ -337,7 +491,7 @@ func (service *Service) BeginApproval(ctx context.Context, userID string, bindin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return BeginResult{}, err
|
return BeginResult{}, err
|
||||||
}
|
}
|
||||||
adapter, err := service.user(ctx, account)
|
adapter, err := service.user(ctx, account, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BeginResult{}, err
|
return BeginResult{}, err
|
||||||
}
|
}
|
||||||
@@ -367,7 +521,7 @@ func (service *Service) FinishApproval(ctx context.Context, ceremonyToken string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Approval{}, err
|
return Approval{}, err
|
||||||
}
|
}
|
||||||
adapter, err := service.user(ctx, account)
|
adapter, err := service.user(ctx, account, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Approval{}, err
|
return Approval{}, err
|
||||||
}
|
}
|
||||||
@@ -504,8 +658,8 @@ func (service *Service) takeCeremony(ctx context.Context, token, kind string) (C
|
|||||||
return ceremony, nil
|
return ceremony, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) user(ctx context.Context, account auth.User) (*passkeyUser, error) {
|
func (service *Service) user(ctx context.Context, account auth.User, allowPending bool) (*passkeyUser, error) {
|
||||||
if account.Status != "active" {
|
if account.Status != "active" || account.RegistrationPending && !allowPending {
|
||||||
return nil, auth.ErrInactiveUser
|
return nil, auth.ErrInactiveUser
|
||||||
}
|
}
|
||||||
records, err := service.repository.CredentialsByUserID(ctx, account.ID)
|
records, err := service.repository.CredentialsByUserID(ctx, account.ID)
|
||||||
@@ -594,12 +748,28 @@ func credentialRemovalBinding(userID string, credentialID []byte) []byte {
|
|||||||
return []byte("gamertan-web/passkey-remove/v1\x00" + userID + "\x00" + base64.RawURLEncoding.EncodeToString(credentialID))
|
return []byte("gamertan-web/passkey-remove/v1\x00" + userID + "\x00" + base64.RawURLEncoding.EncodeToString(credentialID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateOrigin(rpID, rawOrigin string) error {
|
func passwordMigrationBinding(userID string) [32]byte {
|
||||||
|
return BindingDigest([]byte("gamertan-web/password-to-passkey/v1\x00" + userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOrigin(rpID, rawOrigin string, allowDevelopmentPort bool) error {
|
||||||
if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" {
|
if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" {
|
||||||
return errors.New("authwebauthn: relying-party ID and origin are required")
|
return errors.New("authwebauthn: relying-party ID and origin are required")
|
||||||
}
|
}
|
||||||
origin, err := url.Parse(rawOrigin)
|
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 errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ package authwebauthn_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
@@ -52,6 +54,12 @@ func TestBootstrapEnrollmentAndApprovalPolicy(t *testing.T) {
|
|||||||
if !begin.ExpiresAt.Equal(now.Add(5 * time.Minute)) {
|
if !begin.ExpiresAt.Equal(now.Add(5 * time.Minute)) {
|
||||||
t.Fatalf("registration expiry=%v", begin.ExpiresAt)
|
t.Fatalf("registration expiry=%v", begin.ExpiresAt)
|
||||||
}
|
}
|
||||||
|
if _, err = service.FinishRegistrationForUser(t.Context(), begin.CeremonyToken, "another-user", []byte(`{}`)); !errors.Is(err, authwebauthn.ErrOperationBinding) {
|
||||||
|
t.Fatalf("cross-account registration completion err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = service.FinishRegistrationForUser(t.Context(), begin.CeremonyToken, user.ID, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
|
||||||
|
t.Fatalf("mismatched completion did not consume ceremony: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err = service.RequireReady(t.Context(), user.ID); !errors.Is(err, authwebauthn.ErrPasskeyReadiness) {
|
if err = service.RequireReady(t.Context(), user.ID); !errors.Is(err, authwebauthn.ErrPasskeyReadiness) {
|
||||||
t.Fatalf("readiness without credentials err=%v", err)
|
t.Fatalf("readiness without credentials err=%v", err)
|
||||||
@@ -140,6 +148,75 @@ func TestRecoveryRevokesSessionsAndIssuesSingleUseEnrollment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecoveryRegistrationIsBoundAndConsumesMismatchedCeremony(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 3, 13, 0, 0, 0, time.UTC)
|
||||||
|
store, authService, service := newService(t, &now, &counterReader{})
|
||||||
|
defer store.Close()
|
||||||
|
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.bound", Email: "recover-bound@example.test", DisplayName: "Recover Bound", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
binding := bytes.Repeat([]byte("restricted recovery grant "), 2)
|
||||||
|
begin, err := service.BeginRecoveryRegistration(t.Context(), user.ID, "Replacement passkey", binding)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = service.FinishRecoveryRegistration(t.Context(), begin.CeremonyToken, append([]byte(nil), binding[:len(binding)-1]...), []byte(`{}`), func(context.Context, authwebauthn.Credential, auth.AuditEvent) error { return nil }); !errors.Is(err, authwebauthn.ErrOperationBinding) {
|
||||||
|
t.Fatalf("tampered recovery binding err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = service.FinishRecoveryRegistration(t.Context(), begin.CeremonyToken, binding, []byte(`{}`), func(context.Context, authwebauthn.Credential, auth.AuditEvent) error { return nil }); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
|
||||||
|
t.Fatalf("mismatched completion did not consume recovery ceremony: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = service.BeginRecoveryRegistration(t.Context(), user.ID, "Replacement passkey", []byte("short")); !errors.Is(err, authwebauthn.ErrOperationBinding) {
|
||||||
|
t.Fatalf("short recovery binding err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordMigrationCeremonyIsBoundAndUnavailableAfterRetirement(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||||
|
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
authService, err := auth.New(store, auth.Options{Random: &counterReader{}, Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "legacy.user", Email: "legacy@example.test", DisplayName: "Legacy User", Password: "legacy migration password"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "observatory.test", RPDisplayName: "Observatory", Origin: "https://observatory.test", RequiredCredentialCount: 1, Random: &counterReader{}, Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
begin, err := service.BeginPasswordMigration(t.Context(), user.ID, "Primary passkey")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256([]byte(begin.CeremonyToken))
|
||||||
|
ceremony, err := store.TakeCeremony(t.Context(), digest, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ceremony.Kind != authwebauthn.CeremonyRegistration || ceremony.BindingDigest == ([32]byte{}) || ceremony.UserID != user.ID {
|
||||||
|
t.Fatalf("unexpected migration ceremony: %+v", ceremony)
|
||||||
|
}
|
||||||
|
credential := wa.Credential{ID: bytes.Repeat([]byte{9}, 32), PublicKey: []byte{1, 2, 3}}
|
||||||
|
encoded, err := json.Marshal(credential)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
audit := auth.AuditEvent{ID: "migration-direct", ActorUserID: user.ID, Action: "auth.passkey.migrate", ResourceType: "passkey", ResourceID: "credential", Summary: "migration", CreatedAt: now}
|
||||||
|
if err = store.SaveCredentialAndRetirePassword(t.Context(), authwebauthn.Credential{ID: credential.ID, UserID: user.ID, Label: "Primary", Data: encoded, CreatedAt: now}, audit); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = service.BeginPasswordMigration(t.Context(), user.ID, "Replay"); !errors.Is(err, authwebauthn.ErrPasswordNotAvailable) {
|
||||||
|
t.Fatalf("retired password migration err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigurationAndEntropyFailures(t *testing.T) {
|
func TestConfigurationAndEntropyFailures(t *testing.T) {
|
||||||
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
|
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -154,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: "http://tend.gamertan.com"},
|
||||||
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://other.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: "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 {
|
if _, err = authwebauthn.New(store, authService, config); err == nil {
|
||||||
t.Fatalf("accepted config=%+v", config)
|
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{}})
|
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: failingReader{}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
+14
-4
@@ -1,9 +1,10 @@
|
|||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
// Package authwebauthn provides storage-neutral, passkey-only WebAuthn
|
// Package authwebauthn provides storage-neutral WebAuthn ceremonies for
|
||||||
// ceremonies. It owns relying-party policy, bounded single-use ceremony state,
|
// passkey login, enrollment, and operation-bound step-up. It owns relying-party
|
||||||
// credential lifecycle, and recovery tokens while delegating protocol parsing
|
// policy, bounded single-use ceremony state, credential lifecycle, and recovery
|
||||||
// and signature verification to a pinned WebAuthn implementation.
|
// tokens while delegating protocol parsing and signature verification to a
|
||||||
|
// pinned WebAuthn implementation.
|
||||||
package authwebauthn
|
package authwebauthn
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -25,6 +26,7 @@ var (
|
|||||||
ErrOperationBinding = errors.New("authwebauthn: operation binding does not match")
|
ErrOperationBinding = errors.New("authwebauthn: operation binding does not match")
|
||||||
ErrPasskeyReadiness = errors.New("authwebauthn: at least two passkeys are required")
|
ErrPasskeyReadiness = errors.New("authwebauthn: at least two passkeys are required")
|
||||||
ErrUnsupportedCredential = errors.New("authwebauthn: credential algorithm is unsupported")
|
ErrUnsupportedCredential = errors.New("authwebauthn: credential algorithm is unsupported")
|
||||||
|
ErrPasswordNotAvailable = errors.New("authwebauthn: password migration is not available")
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -91,6 +93,12 @@ type Approval struct {
|
|||||||
ApprovedAt time.Time
|
ApprovedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegistrationCommit lets a higher-level account workflow commit a verified
|
||||||
|
// initial credential together with the rest of the account state. The
|
||||||
|
// callback receives only public-key credential material and a secret-free
|
||||||
|
// audit event.
|
||||||
|
type RegistrationCommit func(context.Context, Credential, auth.AuditEvent) error
|
||||||
|
|
||||||
// Repository persists passkey-specific state. Implementations must consume
|
// Repository persists passkey-specific state. Implementations must consume
|
||||||
// enrollment tokens and ceremonies atomically and must perform recovery and
|
// enrollment tokens and ceremonies atomically and must perform recovery and
|
||||||
// credential removal invariants in transactions.
|
// credential removal invariants in transactions.
|
||||||
@@ -100,7 +108,9 @@ type Repository interface {
|
|||||||
UserByIdentifier(context.Context, string) (auth.User, error)
|
UserByIdentifier(context.Context, string) (auth.User, error)
|
||||||
UserByCredentialID(context.Context, []byte) (auth.User, error)
|
UserByCredentialID(context.Context, []byte) (auth.User, error)
|
||||||
CredentialsByUserID(context.Context, string) ([]Credential, error)
|
CredentialsByUserID(context.Context, string) ([]Credential, error)
|
||||||
|
PasswordCredentialExists(context.Context, string) (bool, error)
|
||||||
SaveCredential(context.Context, Credential, auth.AuditEvent) error
|
SaveCredential(context.Context, Credential, auth.AuditEvent) error
|
||||||
|
SaveCredentialAndRetirePassword(context.Context, Credential, auth.AuditEvent) error
|
||||||
UpdateCredential(context.Context, Credential) error
|
UpdateCredential(context.Context, Credential) error
|
||||||
DeleteCredential(context.Context, string, []byte, int, auth.AuditEvent) error
|
DeleteCredential(context.Context, string, []byte, int, auth.AuditEvent) error
|
||||||
CredentialCount(context.Context, string) (int, error)
|
CredentialCount(context.Context, string) (int, error)
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Package bootstrap creates the first application owner and non-personal
|
||||||
|
// organization as one storage transaction. It is intended for a root-local
|
||||||
|
// operator command, not for public registration or a network administration
|
||||||
|
// endpoint.
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/authwebauthn"
|
||||||
|
"gamertan.com/web/organizations"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultEnrollmentLifetime = 15 * time.Minute
|
||||||
|
|
||||||
|
var (
|
||||||
|
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
|
||||||
|
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||||
|
rolePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Input is the reviewed, non-secret identity and organization metadata from a
|
||||||
|
// local operator command.
|
||||||
|
type Input struct {
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
DisplayName string
|
||||||
|
OrganizationSlug string
|
||||||
|
OrganizationName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup is the complete secret-free state a repository must commit atomically.
|
||||||
|
// Enrollment contains only a digest; the raw token remains in the Result.
|
||||||
|
type Setup struct {
|
||||||
|
User auth.User
|
||||||
|
Enrollment authwebauthn.EnrollmentToken
|
||||||
|
Organization organizations.Organization
|
||||||
|
Membership organizations.Membership
|
||||||
|
OwnerBinding access.Binding
|
||||||
|
AuthAudit auth.AuditEvent
|
||||||
|
OrganizationAudit organizations.AuditEvent
|
||||||
|
AccessAudit access.AuditEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result contains the created public records and the one-time enrollment
|
||||||
|
// secret. Applications must deliver EnrollmentToken through a private channel
|
||||||
|
// and must never log it.
|
||||||
|
type Result struct {
|
||||||
|
User auth.User
|
||||||
|
Organization organizations.Organization
|
||||||
|
EnrollmentToken string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository owns the single transaction spanning identity, enrollment,
|
||||||
|
// organization membership, owner access, and their audit events.
|
||||||
|
type Repository interface {
|
||||||
|
CreateInitialOwner(context.Context, Setup) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
OwnerRole string
|
||||||
|
EnrollmentLifetime time.Duration
|
||||||
|
Random io.Reader
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repository Repository
|
||||||
|
ownerRole string
|
||||||
|
enrollmentLifetime time.Duration
|
||||||
|
random io.Reader
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repository Repository, options Options) (*Service, error) {
|
||||||
|
if repository == nil {
|
||||||
|
return nil, errors.New("bootstrap: repository is required")
|
||||||
|
}
|
||||||
|
if !rolePattern.MatchString(options.OwnerRole) {
|
||||||
|
return nil, errors.New("bootstrap: owner role is invalid")
|
||||||
|
}
|
||||||
|
if options.EnrollmentLifetime == 0 {
|
||||||
|
options.EnrollmentLifetime = defaultEnrollmentLifetime
|
||||||
|
}
|
||||||
|
if options.EnrollmentLifetime < time.Minute || options.EnrollmentLifetime > time.Hour {
|
||||||
|
return nil, errors.New("bootstrap: enrollment lifetime is invalid")
|
||||||
|
}
|
||||||
|
if options.Random == nil {
|
||||||
|
options.Random = rand.Reader
|
||||||
|
}
|
||||||
|
if options.Now == nil {
|
||||||
|
options.Now = time.Now
|
||||||
|
}
|
||||||
|
return &Service{repository: repository, ownerRole: options.OwnerRole, enrollmentLifetime: options.EnrollmentLifetime, random: options.Random, now: options.Now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start atomically creates one active passkey-only owner, one active
|
||||||
|
// non-personal organization, direct owner access, and a single-use enrollment
|
||||||
|
// token. It does not create a session or expose a network bootstrap surface.
|
||||||
|
func (service *Service) Start(ctx context.Context, input Input) (Result, error) {
|
||||||
|
input.Username = strings.TrimSpace(input.Username)
|
||||||
|
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||||
|
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||||
|
input.OrganizationSlug = strings.ToLower(strings.TrimSpace(input.OrganizationSlug))
|
||||||
|
input.OrganizationName = strings.TrimSpace(input.OrganizationName)
|
||||||
|
if !identifierPattern.MatchString(input.Username) || !canonicalEmail(input.Email) || !bounded(input.DisplayName, 128) || !slugPattern.MatchString(input.OrganizationSlug) || !bounded(input.OrganizationName, 128) {
|
||||||
|
return Result{}, errors.New("bootstrap: invalid owner or organization")
|
||||||
|
}
|
||||||
|
values, err := service.randomValues(7)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
now := service.now().UTC()
|
||||||
|
userID, organizationID, bindingID := values[0], values[1], values[2]
|
||||||
|
rawToken := values[3]
|
||||||
|
user := auth.User{ID: userID, Username: input.Username, Email: input.Email, DisplayName: input.DisplayName, Status: "active", CreatedAt: now, UpdatedAt: now}
|
||||||
|
organization := organizations.Organization{ID: organizationID, Slug: input.OrganizationSlug, Name: input.OrganizationName, Status: "active", Revision: 1, CreatedAt: now, UpdatedAt: now}
|
||||||
|
enrollment := authwebauthn.EnrollmentToken{Digest: sha256.Sum256([]byte(rawToken)), UserID: userID, CreatedAt: now, ExpiresAt: now.Add(service.enrollmentLifetime)}
|
||||||
|
membership := organizations.Membership{OrganizationID: organizationID, UserID: userID, Status: "active", JoinedAt: now}
|
||||||
|
binding := access.Binding{ID: bindingID, SubjectKind: access.User, SubjectID: userID, Role: service.ownerRole, Scope: access.Scope{OrganizationID: organizationID}, GrantedBy: userID, GrantedAt: now}
|
||||||
|
setup := Setup{
|
||||||
|
User: user,
|
||||||
|
Enrollment: enrollment,
|
||||||
|
Organization: organization,
|
||||||
|
Membership: membership,
|
||||||
|
OwnerBinding: binding,
|
||||||
|
AuthAudit: auth.AuditEvent{ID: values[4], ActorUserID: userID, Action: "auth.passkey.bootstrap", ResourceType: "user", ResourceID: userID, Summary: "A local operator created the initial passkey-only owner and one-time enrollment token.", CreatedAt: now},
|
||||||
|
OrganizationAudit: organizations.AuditEvent{ID: values[5], OrganizationID: organizationID, ActorUserID: userID, Action: "organization.bootstrap", ResourceType: "organization", ResourceID: organizationID, Summary: "A local operator created the initial organization.", CreatedAt: now},
|
||||||
|
AccessAudit: access.AuditEvent{ID: values[6], OrganizationID: organizationID, ActorUserID: userID, Action: "access.binding.grant", ResourceType: "binding", ResourceID: bindingID, Summary: "The initial owner received direct organization access.", CreatedAt: now},
|
||||||
|
}
|
||||||
|
if err = service.repository.CreateInitialOwner(ctx, setup); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
return Result{User: user, Organization: organization, EnrollmentToken: rawToken, ExpiresAt: enrollment.ExpiresAt}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) randomValues(count int) ([]string, error) {
|
||||||
|
values := make([]string, count)
|
||||||
|
for index := range values {
|
||||||
|
bytes := make([]byte, 24)
|
||||||
|
if _, err := io.ReadFull(service.random, bytes); err != nil {
|
||||||
|
return nil, fmt.Errorf("bootstrap: secure randomness unavailable: %w", err)
|
||||||
|
}
|
||||||
|
values[index] = base64.RawURLEncoding.EncodeToString(bytes)
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalEmail(value string) bool {
|
||||||
|
if value == "" || len(value) > 320 || strings.ContainsAny(value, "\x00\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
address, err := mail.ParseAddress(value)
|
||||||
|
return err == nil && address.Name == "" && address.Address == value
|
||||||
|
}
|
||||||
|
|
||||||
|
func bounded(value string, maximum int) bool {
|
||||||
|
return value != "" && len(value) <= maximum && !strings.ContainsAny(value, "\x00\r\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recordingRepository struct {
|
||||||
|
setup Setup
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repository *recordingRepository) CreateInitialOwner(_ context.Context, setup Setup) error {
|
||||||
|
repository.setup = setup
|
||||||
|
return repository.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartBuildsAtomicInitialOwnerSetup(t *testing.T) {
|
||||||
|
repository := new(recordingRepository)
|
||||||
|
now := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC)
|
||||||
|
service, err := New(repository, Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := service.Start(t.Context(), Input{Username: "cole.owner", Email: "COLE@EXAMPLE.TEST", DisplayName: "Cole Speelman", OrganizationSlug: "Gamertan", OrganizationName: "Gamertan"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
setup := repository.setup
|
||||||
|
if result.EnrollmentToken == "" || setup.Enrollment.Digest == [32]byte{} || result.User.Email != "cole@example.test" || result.Organization.Personal || result.Organization.Status != "active" {
|
||||||
|
t.Fatalf("result=%+v setup=%+v", result, setup)
|
||||||
|
}
|
||||||
|
if setup.Membership.UserID != result.User.ID || setup.Membership.OrganizationID != result.Organization.ID || setup.OwnerBinding.Role != "home.owner" || setup.OwnerBinding.GrantedBy != result.User.ID {
|
||||||
|
t.Fatalf("membership=%+v binding=%+v", setup.Membership, setup.OwnerBinding)
|
||||||
|
}
|
||||||
|
if setup.AuthAudit.ID == setup.OrganizationAudit.ID || setup.OrganizationAudit.ID == setup.AccessAudit.ID || setup.AuthAudit.Summary == "" || setup.AccessAudit.ResourceID != setup.OwnerBinding.ID {
|
||||||
|
t.Fatalf("audits=%+v %+v %+v", setup.AuthAudit, setup.OrganizationAudit, setup.AccessAudit)
|
||||||
|
}
|
||||||
|
if !result.ExpiresAt.Equal(now.Add(15 * time.Minute)) {
|
||||||
|
t.Fatalf("expires=%v", result.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartRejectsUnsafeInputAndDoesNotCommit(t *testing.T) {
|
||||||
|
repository := new(recordingRepository)
|
||||||
|
service, err := New(repository, Options{OwnerRole: "home.owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, input := range []Input{
|
||||||
|
{Username: "x", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"},
|
||||||
|
{Username: "owner.user", Email: "Owner <owner@example.test>", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"},
|
||||||
|
{Username: "owner.user", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "bad/slug", OrganizationName: "Gamertan"},
|
||||||
|
} {
|
||||||
|
if _, startErr := service.Start(t.Context(), input); startErr == nil {
|
||||||
|
t.Fatalf("unsafe input accepted: %+v", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if repository.setup.User.ID != "" {
|
||||||
|
t.Fatal("repository was called for rejected input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartDoesNotReturnSecretAfterRepositoryFailure(t *testing.T) {
|
||||||
|
repository := &recordingRepository{err: errors.New("commit failed")}
|
||||||
|
service, err := New(repository, Options{OwnerRole: "home.owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := service.Start(t.Context(), Input{Username: "owner.user", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"})
|
||||||
|
if err == nil || result.EnrollmentToken != "" {
|
||||||
|
t.Fatalf("result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Package web is the documentation root for Gamertan Web Foundations.
|
||||||
|
//
|
||||||
|
// Web Foundations is a collection of small, composable Go packages for the
|
||||||
|
// security-sensitive edges of a web application: request identity, structured
|
||||||
|
// request evidence, browser security, authentication, passkeys, permissions,
|
||||||
|
// organizations, SQLite persistence, abuse controls, and private analytics.
|
||||||
|
//
|
||||||
|
// It is a toolkit rather than an application framework. Applications keep
|
||||||
|
// their router, handlers, HTML, authorization decisions, deployment, and
|
||||||
|
// operational policy. Packages use net/http and can be adopted independently.
|
||||||
|
// No Redis, message broker, hosted identity provider, telemetry service, or
|
||||||
|
// JavaScript framework is required.
|
||||||
|
//
|
||||||
|
// # Choose a first boundary
|
||||||
|
//
|
||||||
|
// Start with the smallest package that owns the boundary you need:
|
||||||
|
//
|
||||||
|
// - [requestmeta] resolves request IDs, client addresses, and trusted-proxy
|
||||||
|
// metadata once for downstream security and logging.
|
||||||
|
// - [requestlog] records bounded, versioned request observations with
|
||||||
|
// sensitive fields disabled by default.
|
||||||
|
// - [websec] supplies HTTP headers, same-origin checks, CSRF protection,
|
||||||
|
// redirects, body limits, and rate limits.
|
||||||
|
// - [auth], [authhttp], [authwebauthn], and [authsqlite] provide
|
||||||
|
// storage-neutral identity, secure browser sessions, passkeys, and an
|
||||||
|
// optional no-CGO SQLite adapter.
|
||||||
|
// - [organizations] and [access] model organizations, teams, invitations,
|
||||||
|
// scoped roles, and audited temporary access.
|
||||||
|
// - [abuse] applies application-classified request-abuse decisions.
|
||||||
|
// - [analytics] creates bounded, disposable projections from requestlog
|
||||||
|
// records without becoming a telemetry service.
|
||||||
|
//
|
||||||
|
// # Compose with net/http
|
||||||
|
//
|
||||||
|
// Middleware is wrapped from the application outward. A request metadata
|
||||||
|
// resolver should be outermost so packages inside it agree about request
|
||||||
|
// identity. The package example shows a complete, executable composition.
|
||||||
|
// A copyable server with graceful shutdown and optional private JSONL logging
|
||||||
|
// is available in the repository's starters/basic directory.
|
||||||
|
//
|
||||||
|
// # Security model
|
||||||
|
//
|
||||||
|
// Untrusted values are bounded before storage or aggregation. Forwarding
|
||||||
|
// headers affect identity only through explicitly trusted proxies. Sensitive
|
||||||
|
// request fields require field-by-field opt-in. Security-relevant
|
||||||
|
// configuration and persistence failures fail closed rather than silently
|
||||||
|
// weakening policy.
|
||||||
|
//
|
||||||
|
// This root package intentionally exports no runtime API. Applications import
|
||||||
|
// only the subpackages they use.
|
||||||
|
//
|
||||||
|
// [abuse]: https://pkg.go.dev/gamertan.com/web/abuse
|
||||||
|
// [access]: https://pkg.go.dev/gamertan.com/web/access
|
||||||
|
// [analytics]: https://pkg.go.dev/gamertan.com/web/analytics
|
||||||
|
// [auth]: https://pkg.go.dev/gamertan.com/web/auth
|
||||||
|
// [authhttp]: https://pkg.go.dev/gamertan.com/web/authhttp
|
||||||
|
// [authsqlite]: https://pkg.go.dev/gamertan.com/web/authsqlite
|
||||||
|
// [authwebauthn]: https://pkg.go.dev/gamertan.com/web/authwebauthn
|
||||||
|
// [organizations]: https://pkg.go.dev/gamertan.com/web/organizations
|
||||||
|
// [requestlog]: https://pkg.go.dev/gamertan.com/web/requestlog
|
||||||
|
// [requestmeta]: https://pkg.go.dev/gamertan.com/web/requestmeta
|
||||||
|
// [websec]: https://pkg.go.dev/gamertan.com/web/websec
|
||||||
|
package web
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MPL-2.0 -->
|
||||||
|
|
||||||
|
# Web Foundations dogfood notes
|
||||||
|
|
||||||
|
This living note records concrete pressure discovered while Gamertan services
|
||||||
|
adopt Web Foundations. It is implementation evidence, not a promise that every
|
||||||
|
application concern belongs in the shared module.
|
||||||
|
|
||||||
|
## Gamertan accounts and commerce
|
||||||
|
|
||||||
|
- Instance operators need all-user/all-organization directories, not a staff
|
||||||
|
roster or implicit membership in every business. Optional bounded readers now
|
||||||
|
expose identity/profile records without credentials, independent of membership.
|
||||||
|
The application must authorize each call through an explicit instance scope;
|
||||||
|
these readers intentionally contain no Gamertan-specific roles or UI policy.
|
||||||
|
Stable-ID cursors and literal searches are covered against pagination gaps,
|
||||||
|
renamed profiles, inactive/personal records and wildcard/query injection.
|
||||||
|
- Customer profile and membership editing requires current ownership for every
|
||||||
|
write, not just changes involving another owner. The existing generic methods
|
||||||
|
intentionally permit application-authorized delegated administrators, so an
|
||||||
|
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
|
||||||
|
and an initial RBAC owner. The historical organization creation method commits
|
||||||
|
membership but no access binding. The new `CreateOwnedOrganization` extension
|
||||||
|
grants the application-configured role and writes both audits atomically for
|
||||||
|
an existing active, fully registered user. It rejects missing roles and
|
||||||
|
unsupported adapters instead of leaving an ownerless organization behind.
|
||||||
|
SQLite tests inject failure at every write stage, including the second audit,
|
||||||
|
and race duplicate creates. Customer/merchant vocabulary remains application
|
||||||
|
policy; there is no new database schema or commerce dependency in Foundations.
|
||||||
|
|
||||||
|
- The account email remains required and unique. Gamertan uses normalized
|
||||||
|
email as the canonical login identifier and keeps username as a stable public
|
||||||
|
identity. Until a mail package exists, the application must not describe an
|
||||||
|
address as verified merely because it was entered during registration.
|
||||||
|
- Password authentication is sufficient for an ordinary customer base
|
||||||
|
session. Privileged application actions use an exact operation binding with
|
||||||
|
`authwebauthn.BeginApproval` and `FinishApproval`; that is safer than a broad
|
||||||
|
long-lived "elevated" session. A user without a passkey can use ordinary
|
||||||
|
features but must enroll one before performing protected work.
|
||||||
|
- `auth.Service.VerifyPassword` remains available for flows that truly require
|
||||||
|
password plus passkey before session issuance.
|
||||||
|
- Public registration exposed a cross-package transaction boundary. The
|
||||||
|
`account` package now keeps an unusable bounded registration draft and makes
|
||||||
|
recovery-code digests, personal organization, membership, owner binding,
|
||||||
|
activation, audits, and an optional initial passkey one repository commit.
|
||||||
|
A failed WebAuthn ceremony can be restarted, or an ordinary password account
|
||||||
|
can finish without it, without persisting a partly privileged account.
|
||||||
|
- Media belongs behind a storage-neutral interface with a hardened local
|
||||||
|
adapter. Content workflow, references, and authorization remain application
|
||||||
|
policy.
|
||||||
|
- Historical `authsqlite.Open` still migrates for compatibility. Applications
|
||||||
|
with reviewed deployment gates use `OpenWithOptions` with migration disabled,
|
||||||
|
require the current schema at startup, and invoke `Migrate` only from an
|
||||||
|
explicit operator command.
|
||||||
|
- Commerce remains a separately versioned nested module so payment-provider
|
||||||
|
policy and catalog evolution do not enlarge the authentication core.
|
||||||
|
- Self-service enrollment exposed an authorization seam: completing a valid
|
||||||
|
ceremony and checking its user only after persistence is too late.
|
||||||
|
`FinishRegistrationForUser` now consumes mismatched ceremonies and checks
|
||||||
|
the application-authenticated user before storing a credential.
|
||||||
|
- First-owner provisioning exposed another cross-package transaction boundary.
|
||||||
|
`bootstrap` now commits the passkey-only user, enrollment digest,
|
||||||
|
non-personal organization, membership, direct owner binding, and audits
|
||||||
|
together. Applications must seed their owner role first and must write the
|
||||||
|
returned raw token only to a newly created private file.
|
||||||
|
- Recovery-code consumption alone is not a complete recovery path. The
|
||||||
|
restricted grant must survive an interrupted authenticator prompt yet be
|
||||||
|
consumed in the same transaction that stores the verified replacement
|
||||||
|
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.
|
||||||
|
- Extending that page to membership suspension, reactivation, and removal
|
||||||
|
exposed the same time-of-check gap in the older lifecycle methods. The new
|
||||||
|
optimistic extension serializes on the active administrator membership,
|
||||||
|
rechecks the exact state bound into the passkey assertion, applies team and
|
||||||
|
direct-binding consequences, and writes the audit in one transaction.
|
||||||
|
- Human-assisted recovery cannot safely be expressed as a root command behind
|
||||||
|
an HTTP button. Preview 18 adds a distinct owner-assisted protocol: the
|
||||||
|
application performs the human review and fresh operation-bound passkey
|
||||||
|
ceremony, while the SQLite transaction rechecks an active direct owner,
|
||||||
|
invalidates every old account authenticator, stores only the grant digest,
|
||||||
|
and writes identity plus organization audits. Grant completion installs the
|
||||||
|
replacement password, passkey, and recovery-code set atomically and never
|
||||||
|
issues a session.
|
||||||
|
- Gamertan's distinction between Site Admin and Owner exposed a second
|
||||||
|
composition boundary: permission to manage ordinary staff must not imply
|
||||||
|
permission to create, demote, suspend, or remove an Owner. Preview 19 moves
|
||||||
|
that invariant into the same SQLite transactions as direct-role and
|
||||||
|
membership changes, while leaving the application's role vocabulary and UI
|
||||||
|
policy application-owned.
|
||||||
|
- Gamertan's invitation work found the same authority boundary before a route
|
||||||
|
was exposed: Site Admin must be able to invite ordinary staff without being
|
||||||
|
able to grant or cancel Owner access. Preview 20 passes the configured owner
|
||||||
|
role into invitation mutations and rechecks a current active direct Owner
|
||||||
|
after acquiring the SQLite write lock. The application still owns fresh
|
||||||
|
authentication, recipient delivery, and the one-time secret presentation.
|
||||||
|
- A real Bitwarden/Vaultwarden owner enrollment reached successful WebAuthn
|
||||||
|
verification but was rejected by a redundant algorithm check because the
|
||||||
|
application's direct response serializer omitted the optional browser
|
||||||
|
`publicKeyAlgorithm` convenience member. Preview 21 keeps ES256-only policy
|
||||||
|
enforcement but derives it from the verified COSE key embedded in
|
||||||
|
authenticator data. This makes the server independent of serializer-specific
|
||||||
|
convenience fields without weakening origin, challenge, user-verification,
|
||||||
|
or algorithm validation.
|
||||||
+13
-1
@@ -19,13 +19,14 @@ install an imagined framework lifecycle around it.
|
|||||||
| SQLite persistence for `auth` | `authsqlite` | Database placement, backup, migration approval, and recovery |
|
| SQLite persistence for `auth` | `authsqlite` | Database placement, backup, migration approval, and recovery |
|
||||||
| One account across organizations and teams | `organizations`, `authsqlite` | Invitation UX, organization naming, and lifecycle policy |
|
| One account across organizations and teams | `organizations`, `authsqlite` | Invitation UX, organization naming, and lifecycle policy |
|
||||||
| Organization-scoped authorization | `access`, `authsqlite` | Role definitions, resource ownership, and route enforcement |
|
| Organization-scoped authorization | `access`, `authsqlite` | Role definitions, resource ownership, and route enforcement |
|
||||||
|
| First passkey-only owner and home organization | `bootstrap`, `authsqlite` | Root-local command, private token file, enrollment page, and owner-role policy |
|
||||||
| Aggregate projections over request records | `analytics` | Collection policy, access control, report UI, and retention |
|
| Aggregate projections over request records | `analytics` | Collection policy, access control, report UI, and retention |
|
||||||
|
|
||||||
The packages are ordinary Go imports. Pin the current preview and verify its
|
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.6
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.25
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -61,6 +62,17 @@ quietly changing identity or policy.
|
|||||||
|
|
||||||
## Bootstrap an account without inventing a permanent password
|
## Bootstrap an account without inventing a permanent password
|
||||||
|
|
||||||
|
For the first application owner, prefer `bootstrap.Start`. After explicitly
|
||||||
|
seeding the application's access policy, it creates the active passkey-only
|
||||||
|
user, non-personal home organization, membership, direct owner binding,
|
||||||
|
enrollment digest, and audit events in one repository transaction. A missing
|
||||||
|
owner role or duplicate identity rolls back every row. The application-owned
|
||||||
|
root-local command writes the returned raw enrollment token once to an
|
||||||
|
exclusive mode-`0600` file and must never print or log it.
|
||||||
|
|
||||||
|
For applications that still require a temporary password bootstrap,
|
||||||
|
`auth.GenerateTemporaryPassword` remains available:
|
||||||
|
|
||||||
`auth.GenerateTemporaryPassword` returns 256 bits of URL-safe cryptographic
|
`auth.GenerateTemporaryPassword` returns 256 bits of URL-safe cryptographic
|
||||||
entropy. An application can store that value in a newly created private file
|
entropy. An application can store that value in a newly created private file
|
||||||
and provision an account with `RequirePasswordChange: true`. The library does
|
and provision an account with `RequirePasswordChange: true`. The library does
|
||||||
|
|||||||
+7
-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.6
|
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
|
||||||
@@ -52,6 +52,12 @@ Do not split merely to make an architecture diagram look modular. Package
|
|||||||
interfaces provide source-level modularity today; modules are introduced only
|
interfaces provide source-level modularity today; modules are introduced only
|
||||||
for an independent dependency and release lifecycle.
|
for an independent dependency and release lifecycle.
|
||||||
|
|
||||||
|
The `media` package and `medialocal` adapter deliberately remain in the root
|
||||||
|
module: they use only the standard library, and applications can adopt the core
|
||||||
|
interface without importing the local adapter. Commerce is different. Its
|
||||||
|
provider SDK and independently evolving catalog/payment contract justify a
|
||||||
|
future nested `gamertan.com/web/commerce` module after application dogfood.
|
||||||
|
|
||||||
## Session boundaries
|
## Session boundaries
|
||||||
|
|
||||||
Authenticated sessions currently belong to three deliberate packages:
|
Authenticated sessions currently belong to three deliberate packages:
|
||||||
|
|||||||
+169
-9
@@ -8,10 +8,161 @@ 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. Acceptance verifies that the authenticated
|
expiring, single-use invitations. An invitation may carry up to sixteen direct
|
||||||
user's normalized email matches the invitation before activating membership.
|
roles and sixteen reviewed team memberships. Acceptance verifies that the
|
||||||
Applications own invitation pages, email or out-of-band delivery, organization
|
authenticated user's normalized email matches and applies the membership,
|
||||||
deletion policy, and account recovery.
|
roles, teams, consumption marker, and audit event in one transaction. The
|
||||||
|
recipient and issuing member must remain active, fully registered users of an
|
||||||
|
active organization; a suspended recipient cannot use an invitation as implicit
|
||||||
|
reactivation. Existing members use the membership editor, not another invitation,
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
For instance-wide administrative directories, the optional
|
||||||
|
`auth.UserDirectoryRepository` and `organizations.DirectoryRepository` readers
|
||||||
|
on `authsqlite.Store` list all identities/organizations, not just memberships.
|
||||||
|
**Authorize an explicit instance-read capability before every call.** These are
|
||||||
|
not customer self-service or public directory APIs; they deliberately include
|
||||||
|
incomplete/inactive accounts and personal/archived organizations without exposing
|
||||||
|
credentials, recovery material or invitations. Merchant classification remains
|
||||||
|
application policy. Reading never creates a membership or grants a role.
|
||||||
|
|
||||||
|
Both queries accept literal `Search` (up to 128 bytes), exclusive `AfterID`, and
|
||||||
|
`Limit` (default 50, maximum 200). An empty `NextID` ends the result. Preserve the
|
||||||
|
search when following a cursor; reset it when changing the search. IDs give stable
|
||||||
|
ordering despite renamed profiles, but pages are current views rather than a
|
||||||
|
multi-request snapshot. New records sorting before a cursor appear on a fresh
|
||||||
|
listing. SQLite search folds ASCII case; non-ASCII display-name text matches with
|
||||||
|
its original case. Wildcards and SQL fragments are always literal search text.
|
||||||
|
These optional readers do not change the required authentication/organization
|
||||||
|
repository contracts or schema 10.
|
||||||
|
|
||||||
|
For an existing authenticated user creating a business, use
|
||||||
|
`CreateOwnedOrganization` with `OwnerRole` configured when constructing the
|
||||||
|
service. Seed that role first. This commits the organization, active membership,
|
||||||
|
direct organization-wide owner binding, and both creation/access audit events in
|
||||||
|
one transaction. `CreateOrganization.RequestID` correlates those audit events.
|
||||||
|
The owner must be an active user whose registration has completed.
|
||||||
|
|
||||||
|
The application authorizes creation and chooses the role; do not accept an owner
|
||||||
|
role name from a browser or API payload. A customer-owner role can intentionally
|
||||||
|
have different permissions from an installation's merchant-owner role. Creating
|
||||||
|
a customer organization grants no authority in any other organization.
|
||||||
|
|
||||||
|
```go
|
||||||
|
customers, err := organizations.New(store, organizations.Options{
|
||||||
|
OwnerRole: "customer.owner", // Application-defined, already seeded.
|
||||||
|
OwnerManagedInvitations: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
business, err := customers.CreateOwnedOrganization(ctx, organizations.CreateOrganization{
|
||||||
|
Slug: "example-business", Name: "Example Business", OwnerUserID: principal.User.ID,
|
||||||
|
RequestID: requestID,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Repositories implement `OwnedOrganizationRepository` to support this operation.
|
||||||
|
There is no create-then-grant fallback: unsupported adapters return
|
||||||
|
`ErrOwnedCreationUnsupported`. The older `CreateOrganization` and
|
||||||
|
`CreatePersonalOrganization` retain their membership-only behavior; configuring
|
||||||
|
`OwnerRole` does not silently change them. The separate `account` package still
|
||||||
|
owns atomic public signup, including personal organization and credentials.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
`active`/`archived` states. Archived objects keep their history but contribute
|
||||||
|
no effective authority. Memberships may be suspended, reactivated, or removed;
|
||||||
|
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.
|
||||||
|
|
||||||
|
Fresh-authentication administration pages should use
|
||||||
|
`ChangeMembershipStatus` and `RemoveMembershipIfCurrent`, passing the exact
|
||||||
|
displayed state as `ExpectedStatus`. The SQLite adapter acquires its write lock
|
||||||
|
before checking that state, verifies the actor is still an active member of an
|
||||||
|
active organization, and commits the lifecycle effects and audit together.
|
||||||
|
Suspension removes team memberships; reactivation does not infer or restore
|
||||||
|
them. Removal also revokes current direct bindings. A repository without the
|
||||||
|
optimistic extension fails closed instead of falling back to a stale mutation.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
@@ -34,8 +185,17 @@ grant organization-data access. If an operator must inspect tenant data during
|
|||||||
an incident, use a reasoned break-glass grant. It expires within one hour and
|
an incident, use a reasoned break-glass grant. It expires within one hour and
|
||||||
creates an append-only audit event in the same transaction.
|
creates an append-only audit event in the same transaction.
|
||||||
|
|
||||||
The SQLite adapter namespaces all tables, enforces organization membership and
|
An application that offers owner-assisted account recovery must not infer that
|
||||||
resource ancestry before accepting a binding, and keeps invitations and
|
authority from a broad administration page. Use the dedicated
|
||||||
sessions as digests. Applications remain responsible for database backup,
|
`authrecovery.IssueAssistedRecovery` boundary after an operation-bound passkey
|
||||||
filesystem ownership, retention, and presenting audit history to organization
|
assertion. The SQLite adapter requires a current active direct owner binding
|
||||||
owners.
|
and active target membership in the same transaction that invalidates the old
|
||||||
|
credentials and records the organization-visible recovery audit. Team,
|
||||||
|
break-glass, platform, and merely descriptive roles do not satisfy this owner
|
||||||
|
check.
|
||||||
|
|
||||||
|
The SQLite adapter namespaces all tables, enforces active organization and team
|
||||||
|
membership plus resource ancestry before accepting or evaluating a binding,
|
||||||
|
and keeps invitations and sessions as digests. Applications remain responsible
|
||||||
|
for database backup, filesystem ownership, retention, and presenting audit
|
||||||
|
history to organization owners.
|
||||||
|
|||||||
+65
-10
@@ -9,11 +9,20 @@ authorization decisions, session cookie, HTML, and local recovery command.
|
|||||||
## Fixed security policy
|
## Fixed security policy
|
||||||
|
|
||||||
- Use an exact HTTPS origin whose hostname equals the relying-party ID.
|
- 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.
|
- Reject cross-origin ceremonies.
|
||||||
- Require discoverable credentials and user verification.
|
- Require discoverable credentials and user verification.
|
||||||
- Request no attestation conveyance.
|
- Request no attestation conveyance.
|
||||||
- Permit ES256 only until another algorithm has explicit interoperability and
|
- Permit ES256 only until another algorithm has explicit interoperability and
|
||||||
security evidence.
|
security evidence.
|
||||||
|
- Enforce that policy from the verified COSE public key embedded in
|
||||||
|
authenticator data. Do not rely on the optional browser
|
||||||
|
`publicKeyAlgorithm` convenience member: direct standards-compliant response
|
||||||
|
serializers may omit it even when the attested credential is ES256.
|
||||||
- Store random challenges and verifier session data only behind opaque,
|
- Store random challenges and verifier session data only behind opaque,
|
||||||
single-use ceremony tokens.
|
single-use ceremony tokens.
|
||||||
- Treat clone warnings as audit signals rather than automatic lockout for
|
- Treat clone warnings as audit signals rather than automatic lockout for
|
||||||
@@ -26,30 +35,76 @@ timestamp, UUID, or counter for the random challenge.
|
|||||||
|
|
||||||
## Application flow
|
## Application flow
|
||||||
|
|
||||||
1. A local command calls `Bootstrap` or `Recover` and writes the returned
|
1. A local command calls `authwebauthn.Bootstrap`, `authwebauthn.Recover`, or
|
||||||
enrollment token once to a newly created mode-`0600` file.
|
`bootstrap.Start` and writes the returned enrollment token once to a newly
|
||||||
|
created mode-`0600` file. Use `bootstrap.Start` for the first application
|
||||||
|
owner so identity, organization membership, direct owner access, and audits
|
||||||
|
cannot be partially committed.
|
||||||
2. A server-rendered enrollment page calls `BeginEnrollment`; the browser uses
|
2. A server-rendered enrollment page calls `BeginEnrollment`; the browser uses
|
||||||
`navigator.credentials.create` with the returned `public_key` value.
|
`navigator.credentials.create` with the returned `public_key` value.
|
||||||
3. The browser posts the credential and opaque ceremony token to a bounded JSON
|
3. The browser posts the credential and opaque ceremony token to a bounded JSON
|
||||||
endpoint; `FinishRegistration` verifies and stores the public credential.
|
endpoint; authenticated self-service flows use
|
||||||
|
`FinishRegistrationForUser` so the application session's user ID is checked
|
||||||
|
before any public credential is stored.
|
||||||
4. Login uses `BeginLogin`, `navigator.credentials.get`, and `FinishLogin`.
|
4. Login uses `BeginLogin`, `navigator.credentials.get`, and `FinishLogin`.
|
||||||
The successful result contains an ordinary opaque `auth` session token.
|
The successful result contains an ordinary opaque `auth` session token.
|
||||||
5. Sensitive operations call `BeginApproval` with a canonical application
|
5. Sensitive operations call `BeginApproval` with a canonical application
|
||||||
payload and `FinishApproval` with those exact same bytes. Any drift fails.
|
payload and `FinishApproval` with those exact same bytes. Any drift fails.
|
||||||
|
|
||||||
|
For an existing password-backed account, call `BeginPasswordMigration` only
|
||||||
|
from an authenticated account session and finish with
|
||||||
|
`FinishPasswordMigration`. The registration ceremony is bound to that user.
|
||||||
|
Successful completion stores the passkey, removes the password credential,
|
||||||
|
clears the password-change flag, revokes every session and pending ceremony,
|
||||||
|
and appends the audit event atomically. The application must clear the current
|
||||||
|
session cookie and return the user to passkey login after success.
|
||||||
|
|
||||||
`authhttp.WritePasskeyBegin` and `authhttp.ReadPasskeyFinish` provide bounded
|
`authhttp.WritePasskeyBegin` and `authhttp.ReadPasskeyFinish` provide bounded
|
||||||
JSON framing only. They do not register routes, authorize requests, serve
|
JSON framing only. They do not register routes, authorize requests, serve
|
||||||
JavaScript, or set sessions automatically.
|
JavaScript, or set sessions automatically.
|
||||||
|
|
||||||
## Recovery and credential lifecycle
|
## Recovery and credential lifecycle
|
||||||
|
|
||||||
Recovery is deliberately host-local and should never be reachable through an
|
Administrator-assisted `authwebauthn.Recover` is deliberately host-local and
|
||||||
HTTP handler. It revokes all user sessions and pending ceremonies, replaces
|
must never be reachable through an HTTP handler. It revokes all user sessions
|
||||||
prior enrollment tokens, appends a secret-free audit event, and returns one
|
and pending ceremonies, replaces prior enrollment tokens, appends a
|
||||||
15-minute token. It does not delete existing passkeys. After enrolling a
|
secret-free audit event, and returns one 15-minute token. It does not delete
|
||||||
replacement, the operator reviews credential labels and removes lost keys with
|
existing passkeys. After enrolling a replacement, the operator reviews
|
||||||
a fresh passkey-bound removal ceremony. The final passkey cannot be removed
|
credential labels and removes lost keys with a fresh passkey-bound removal
|
||||||
remotely.
|
ceremony. The final passkey cannot be removed remotely.
|
||||||
|
|
||||||
|
An account may separately expose self-service password-plus-recovery-code
|
||||||
|
recovery through `authrecovery`. `Begin` verifies the password, consumes one
|
||||||
|
printable code, revokes sessions, and returns a short-lived grant—not a normal
|
||||||
|
session. Keep that grant in a narrowly scoped, Secure, HttpOnly, SameSite cookie
|
||||||
|
and never place it in a URL. `BeginPasskey` binds its digest into the WebAuthn
|
||||||
|
ceremony. `FinishPasskey` atomically consumes the grant, stores the verified
|
||||||
|
replacement passkey, replaces the entire recovery-code set, revokes any
|
||||||
|
sessions or ceremonies created during recovery, and returns the new plaintext
|
||||||
|
codes exactly once. It does not issue a session; return the user to normal
|
||||||
|
login after displaying and saving the new codes.
|
||||||
|
|
||||||
|
A failed storage commit leaves the restricted grant available for a fresh
|
||||||
|
ceremony until expiry. A binding mismatch consumes the mismatched ceremony.
|
||||||
|
Applications must use generic failure responses and the same credential-attempt
|
||||||
|
rate limiting as login.
|
||||||
|
|
||||||
|
Owner-assisted recovery is a third, deliberately separate path. Configure
|
||||||
|
`authrecovery.Options.OwnerRole`, authorize an active direct organization owner,
|
||||||
|
and bind that owner's fresh passkey assertion to the exact organization,
|
||||||
|
target user, request identifier, and bounded human-review reason before calling
|
||||||
|
`IssueAssistedRecovery`. The SQLite transaction rechecks the active direct
|
||||||
|
owner and target membership, invalidates the target's password, passkeys,
|
||||||
|
recovery codes, sessions, and pending ceremonies, then stores only a digest of
|
||||||
|
the 15-minute grant with identity and organization-visible audits.
|
||||||
|
|
||||||
|
Deliver the returned grant exactly once in a URL fragment. A public recovery
|
||||||
|
page can pass it to `BeginAssistedPasskey` and `FinishAssistedRecovery` while
|
||||||
|
keeping it out of request URLs, referrers, and access logs. Completion consumes
|
||||||
|
the grant atomically with one replacement password, passkey, recovery-code set,
|
||||||
|
and both audit trails. It issues no session. Losing the fragment after issuance
|
||||||
|
requires another reviewed owner or root-local recovery; old authenticators
|
||||||
|
must not become valid again as a fallback.
|
||||||
|
|
||||||
Before enabling production mutations, applications should require at least two
|
Before enabling production mutations, applications should require at least two
|
||||||
independent passkeys and complete a local recovery drill.
|
independent passkeys and complete a local recovery drill.
|
||||||
|
|||||||
@@ -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,51 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"gamertan.com/web/requestlog"
|
||||||
|
"gamertan.com/web/requestmeta"
|
||||||
|
"gamertan.com/web/websec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example() {
|
||||||
|
resolver, err := requestmeta.New(requestmeta.Config{})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := http.NewServeMux()
|
||||||
|
router.HandleFunc("GET /", func(response http.ResponseWriter, _ *http.Request) {
|
||||||
|
response.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
var handler http.Handler = router
|
||||||
|
handler = requestlog.Middleware(nil, requestlog.Policy{
|
||||||
|
Route: func(*http.Request) string { return "home" },
|
||||||
|
})(handler)
|
||||||
|
handler = websec.Headers(func(*http.Request) websec.HeaderPolicy {
|
||||||
|
return websec.HeaderPolicy{
|
||||||
|
ContentSecurityPolicy: "default-src 'none'; frame-ancestors 'none'",
|
||||||
|
ReferrerPolicy: "no-referrer",
|
||||||
|
FrameOptions: "DENY",
|
||||||
|
}
|
||||||
|
})(handler)
|
||||||
|
handler = resolver.Middleware(handler)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "https://example.test/", nil)
|
||||||
|
request.RemoteAddr = "192.0.2.10:43120"
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
fmt.Println(response.Code)
|
||||||
|
fmt.Println(response.Header().Get("X-Request-ID") != "")
|
||||||
|
fmt.Println(response.Header().Get("X-Content-Type-Options"))
|
||||||
|
// Output:
|
||||||
|
// 204
|
||||||
|
// true
|
||||||
|
// nosniff
|
||||||
|
}
|
||||||
+246
@@ -0,0 +1,246 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Package media defines bounded media preparation and storage-neutral blob
|
||||||
|
// interfaces. Applications retain authorization, references, lifecycle, and
|
||||||
|
// presentation policy.
|
||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindImage = "image"
|
||||||
|
KindAttachment = "attachment"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidMedia = errors.New("media: invalid media")
|
||||||
|
ErrTooLarge = errors.New("media: upload exceeds its size limit")
|
||||||
|
ErrNotFound = errors.New("media: object not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Limits struct {
|
||||||
|
MaxBytes int64
|
||||||
|
MaxWidth int
|
||||||
|
MaxHeight int
|
||||||
|
MaxPixels int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (limits Limits) withDefaults() Limits {
|
||||||
|
if limits.MaxBytes == 0 {
|
||||||
|
limits.MaxBytes = 10 << 20
|
||||||
|
}
|
||||||
|
if limits.MaxWidth == 0 {
|
||||||
|
limits.MaxWidth = 8192
|
||||||
|
}
|
||||||
|
if limits.MaxHeight == 0 {
|
||||||
|
limits.MaxHeight = 8192
|
||||||
|
}
|
||||||
|
if limits.MaxPixels == 0 {
|
||||||
|
limits.MaxPixels = 40_000_000
|
||||||
|
}
|
||||||
|
return limits
|
||||||
|
}
|
||||||
|
|
||||||
|
func (limits Limits) validate() error {
|
||||||
|
if limits.MaxBytes < 1024 || limits.MaxBytes > 100<<20 || limits.MaxWidth < 1 || limits.MaxWidth > 32768 || limits.MaxHeight < 1 || limits.MaxHeight > 32768 || limits.MaxPixels < 1 || limits.MaxPixels > 250_000_000 {
|
||||||
|
return errors.New("media: invalid limits")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepared is a sanitized, bounded object ready for durable storage. Raster
|
||||||
|
// images are decoded and re-encoded so source metadata and unparsed trailing
|
||||||
|
// bytes are not retained. PDFs are attachments and are never inline media.
|
||||||
|
type Prepared struct {
|
||||||
|
Digest [32]byte
|
||||||
|
Data []byte
|
||||||
|
MediaType string
|
||||||
|
Kind string
|
||||||
|
OriginalName string
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (prepared Prepared) Key() string { return hex.EncodeToString(prepared.Digest[:]) }
|
||||||
|
|
||||||
|
type Object struct {
|
||||||
|
Key string
|
||||||
|
Size int64
|
||||||
|
MediaType string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
Put(context.Context, Prepared) (Object, error)
|
||||||
|
Open(context.Context, string) (io.ReadCloser, Object, error)
|
||||||
|
Delete(context.Context, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare reads at most the configured bound and accepts JPEG, PNG, GIF, or a
|
||||||
|
// PDF attachment. Animated images are deliberately flattened to the decoded
|
||||||
|
// first frame. The returned byte slice is owned by the caller.
|
||||||
|
func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, error) {
|
||||||
|
if reader == nil {
|
||||||
|
return Prepared{}, ErrInvalidMedia
|
||||||
|
}
|
||||||
|
limits = limits.withDefaults()
|
||||||
|
if err := limits.validate(); err != nil {
|
||||||
|
return Prepared{}, err
|
||||||
|
}
|
||||||
|
name, err := boundedName(originalName)
|
||||||
|
if err != nil {
|
||||||
|
return Prepared{}, err
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(reader, limits.MaxBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return Prepared{}, fmt.Errorf("media: read upload: %w", err)
|
||||||
|
}
|
||||||
|
if int64(len(data)) > limits.MaxBytes {
|
||||||
|
return Prepared{}, ErrTooLarge
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return Prepared{}, ErrInvalidMedia
|
||||||
|
}
|
||||||
|
|
||||||
|
detected := http.DetectContentType(data)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
imageValue, format, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil || format != "jpeg" && format != "png" && format != "gif" {
|
||||||
|
return Prepared{}, ErrInvalidMedia
|
||||||
|
}
|
||||||
|
bounds := imageValue.Bounds()
|
||||||
|
width, height := bounds.Dx(), bounds.Dy()
|
||||||
|
if width < 1 || height < 1 || width > limits.MaxWidth || height > limits.MaxHeight || int64(width) > limits.MaxPixels/int64(height) {
|
||||||
|
return Prepared{}, ErrTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
mediaType := "image/png"
|
||||||
|
if format == "jpeg" {
|
||||||
|
mediaType = "image/jpeg"
|
||||||
|
err = jpeg.Encode(&output, imageValue, &jpeg.Options{Quality: 90})
|
||||||
|
} else {
|
||||||
|
err = png.Encode(&output, imageValue)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Prepared{}, fmt.Errorf("media: sanitize image: %w", err)
|
||||||
|
}
|
||||||
|
if int64(output.Len()) > limits.MaxBytes {
|
||||||
|
return Prepared{}, ErrTooLarge
|
||||||
|
}
|
||||||
|
result := Prepared{Data: output.Bytes(), MediaType: mediaType, Kind: KindImage, OriginalName: name, Width: width, Height: height}
|
||||||
|
result.Digest = sha256.Sum256(result.Data)
|
||||||
|
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":
|
||||||
|
return ".jpg"
|
||||||
|
case "image/png":
|
||||||
|
return ".png"
|
||||||
|
case "application/pdf":
|
||||||
|
return ".pdf"
|
||||||
|
default:
|
||||||
|
values, _ := mime.ExtensionsByType(mediaType)
|
||||||
|
if len(values) > 0 {
|
||||||
|
return values[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidKey(value string) bool {
|
||||||
|
if len(value) != sha256.Size*2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
decoded, err := hex.DecodeString(value)
|
||||||
|
return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boundedName(value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(filepath.Base(value))
|
||||||
|
if value == "." || value == "" || !utf8.ValidString(value) || len(value) > 240 || strings.ContainsAny(value, "\x00\r\n") {
|
||||||
|
return "", ErrInvalidMedia
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) {
|
||||||
|
var source bytes.Buffer
|
||||||
|
value := image.NewRGBA(image.Rect(0, 0, 3, 2))
|
||||||
|
value.Set(1, 1, color.RGBA{R: 220, G: 20, B: 50, A: 255})
|
||||||
|
if err := jpeg.Encode(&source, value, &jpeg.Options{Quality: 95}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source.WriteString("secret trailing metadata")
|
||||||
|
prepared, err := Prepare(bytes.NewReader(source.Bytes()), " portrait.jpg ", Limits{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if prepared.Kind != KindImage || prepared.MediaType != "image/jpeg" || prepared.Width != 3 || prepared.Height != 2 || prepared.OriginalName != "portrait.jpg" {
|
||||||
|
t.Fatalf("prepared=%+v", prepared)
|
||||||
|
}
|
||||||
|
if bytes.Contains(prepared.Data, []byte("secret trailing metadata")) || prepared.Key() == strings.Repeat("0", 64) {
|
||||||
|
t.Fatal("image source data was not sanitized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparePDFIsAttachment(t *testing.T) {
|
||||||
|
pdf := minimalPDF()
|
||||||
|
prepared, err := Prepare(bytes.NewReader(pdf), "guide.pdf", Limits{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if prepared.Kind != KindAttachment || prepared.MediaType != "application/pdf" {
|
||||||
|
t.Fatalf("prepared=%+v", prepared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if _, err := Prepare(strings.NewReader(strings.Repeat("x", 1025)), "large.png", Limits{MaxBytes: 1024, MaxWidth: 10, MaxHeight: 10, MaxPixels: 100}); !errors.Is(err, ErrTooLarge) {
|
||||||
|
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))...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Package medialocal stores prepared media in a private content-addressed
|
||||||
|
// filesystem tree.
|
||||||
|
package medialocal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
root string
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(root string, options Options) (*Store, error) {
|
||||||
|
absolute, err := filepath.Abs(root)
|
||||||
|
if err != nil || filepath.Clean(absolute) != absolute {
|
||||||
|
return nil, errors.New("medialocal: root must be a clean absolute path")
|
||||||
|
}
|
||||||
|
if err = secureDirectory(absolute, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resolved, err := filepath.EvalSymlinks(absolute)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("medialocal: resolve root: %w", err)
|
||||||
|
}
|
||||||
|
if options.Now == nil {
|
||||||
|
options.Now = time.Now
|
||||||
|
}
|
||||||
|
return &Store{root: resolved, now: options.Now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Put(ctx context.Context, prepared media.Prepared) (media.Object, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return media.Object{}, err
|
||||||
|
}
|
||||||
|
if len(prepared.Data) == 0 || !media.ValidKey(prepared.Key()) || sha256.Sum256(prepared.Data) != prepared.Digest {
|
||||||
|
return media.Object{}, media.ErrInvalidMedia
|
||||||
|
}
|
||||||
|
shard, target := store.objectPath(prepared.Key())
|
||||||
|
if err := secureDirectory(shard, true); err != nil {
|
||||||
|
return media.Object{}, err
|
||||||
|
}
|
||||||
|
if object, ok, err := inspect(target, prepared.MediaType); err != nil {
|
||||||
|
return media.Object{}, err
|
||||||
|
} else if ok {
|
||||||
|
if object.Size != int64(len(prepared.Data)) {
|
||||||
|
return media.Object{}, errors.New("medialocal: existing digest has an unexpected size")
|
||||||
|
}
|
||||||
|
return object, nil
|
||||||
|
}
|
||||||
|
temporary, err := os.CreateTemp(shard, ".upload-*")
|
||||||
|
if err != nil {
|
||||||
|
return media.Object{}, fmt.Errorf("medialocal: create temporary object: %w", err)
|
||||||
|
}
|
||||||
|
temporaryName := temporary.Name()
|
||||||
|
defer os.Remove(temporaryName)
|
||||||
|
if err = temporary.Chmod(0o640); err == nil {
|
||||||
|
_, err = temporary.Write(prepared.Data)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = temporary.Sync()
|
||||||
|
}
|
||||||
|
if closeErr := temporary.Close(); err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return media.Object{}, fmt.Errorf("medialocal: write object: %w", err)
|
||||||
|
}
|
||||||
|
if err = os.Link(temporaryName, target); err != nil {
|
||||||
|
if errors.Is(err, os.ErrExist) {
|
||||||
|
object, ok, inspectErr := inspect(target, prepared.MediaType)
|
||||||
|
if inspectErr != nil {
|
||||||
|
return media.Object{}, inspectErr
|
||||||
|
}
|
||||||
|
if ok && object.Size == int64(len(prepared.Data)) {
|
||||||
|
return object, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return media.Object{}, fmt.Errorf("medialocal: commit object: %w", err)
|
||||||
|
}
|
||||||
|
return media.Object{Key: prepared.Key(), Size: int64(len(prepared.Data)), MediaType: prepared.MediaType, CreatedAt: store.now().UTC()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Open(ctx context.Context, key string) (io.ReadCloser, media.Object, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, media.Object{}, err
|
||||||
|
}
|
||||||
|
if !media.ValidKey(key) {
|
||||||
|
return nil, media.Object{}, media.ErrNotFound
|
||||||
|
}
|
||||||
|
shard, target := store.objectPath(key)
|
||||||
|
if err := secureDirectory(shard, false); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, media.Object{}, media.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, media.Object{}, err
|
||||||
|
}
|
||||||
|
before, err := os.Lstat(target)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, media.Object{}, media.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil || !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return nil, media.Object{}, errors.New("medialocal: object is not a regular file")
|
||||||
|
}
|
||||||
|
file, err := os.Open(target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, media.Object{}, err
|
||||||
|
}
|
||||||
|
after, err := file.Stat()
|
||||||
|
if err != nil || !os.SameFile(before, after) {
|
||||||
|
file.Close()
|
||||||
|
return nil, media.Object{}, errors.New("medialocal: object changed while opening")
|
||||||
|
}
|
||||||
|
return file, media.Object{Key: key, Size: after.Size(), CreatedAt: after.ModTime().UTC()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Delete(ctx context.Context, key string) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !media.ValidKey(key) {
|
||||||
|
return media.ErrNotFound
|
||||||
|
}
|
||||||
|
_, target := store.objectPath(key)
|
||||||
|
info, err := os.Lstat(target)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return media.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return errors.New("medialocal: refusing to delete a non-regular object")
|
||||||
|
}
|
||||||
|
if err = os.Remove(target); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return media.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) objectPath(key string) (string, string) {
|
||||||
|
shard := filepath.Join(store.root, key[:2])
|
||||||
|
return shard, filepath.Join(shard, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func secureDirectory(path string, create bool) error {
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) && create {
|
||||||
|
if err = os.MkdirAll(path, 0o750); err != nil {
|
||||||
|
return fmt.Errorf("medialocal: create directory: %w", err)
|
||||||
|
}
|
||||||
|
info, err = os.Lstat(path)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||||
|
return errors.New("medialocal: storage directory must not be a symlink")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspect(path, mediaType string) (media.Object, bool, error) {
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return media.Object{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return media.Object{}, false, err
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return media.Object{}, false, errors.New("medialocal: existing object is not a regular file")
|
||||||
|
}
|
||||||
|
return media.Object{Key: filepath.Base(path), Size: info.Size(), MediaType: mediaType, CreatedAt: info.ModTime().UTC()}, true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package medialocal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreRoundTripAndIdempotentPut(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
store, err := Open(filepath.Join(t.TempDir(), "media"), Options{Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
first, err := store.Put(t.Context(), prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := store.Put(t.Context(), prepared)
|
||||||
|
if err != nil || second.Key != first.Key || second.Size != first.Size {
|
||||||
|
t.Fatalf("second=%+v err=%v", second, err)
|
||||||
|
}
|
||||||
|
reader, object, err := store.Open(t.Context(), first.Key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, readErr := io.ReadAll(reader)
|
||||||
|
closeErr := reader.Close()
|
||||||
|
if readErr != nil || closeErr != nil || !bytes.Equal(data, prepared.Data) || object.Size != int64(len(data)) {
|
||||||
|
t.Fatalf("round trip object=%+v read=%v close=%v", object, readErr, closeErr)
|
||||||
|
}
|
||||||
|
if err = store.Delete(t.Context(), first.Key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err = store.Open(t.Context(), first.Key); !errors.Is(err, media.ErrNotFound) {
|
||||||
|
t.Fatalf("missing err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRejectsSymlinkRoot(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
target := filepath.Join(base, "target")
|
||||||
|
if err := os.Mkdir(target, 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
link := filepath.Join(base, "link")
|
||||||
|
if err := os.Symlink(target, link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Open(link, Options{}); err == nil {
|
||||||
|
t.Fatal("symlink root accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+467
-48
@@ -14,23 +14,34 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvitationNotFound = errors.New("organizations: invitation not found")
|
ErrInvitationNotFound = errors.New("organizations: invitation not found")
|
||||||
ErrMembershipNotFound = errors.New("organizations: membership not found")
|
ErrMembershipNotFound = errors.New("organizations: membership not found")
|
||||||
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
ErrMembershipLifecycleUnsupported = errors.New("organizations: optimistic membership lifecycle is unsupported")
|
||||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
ErrOrganizationNotFound = errors.New("organizations: organization not found")
|
||||||
|
ErrTeamNotFound = errors.New("organizations: team not found")
|
||||||
|
ErrRevisionConflict = errors.New("organizations: revision conflict")
|
||||||
|
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
|
||||||
|
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
|
||||||
|
ErrOwnerAuthority = errors.New("organizations: a current direct owner must manage owner access")
|
||||||
|
ErrOwnedCreationUnsupported = errors.New("organizations: atomic owned organization creation is unsupported")
|
||||||
|
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||||
|
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
type Organization struct {
|
type Organization struct {
|
||||||
ID string
|
ID string
|
||||||
Slug string
|
Slug string
|
||||||
Name string
|
Name string
|
||||||
Personal bool
|
Status string
|
||||||
CreatedAt time.Time
|
Personal bool
|
||||||
|
Revision int64
|
||||||
|
CreatedAt, UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Membership struct {
|
type Membership struct {
|
||||||
@@ -41,8 +52,9 @@ type Membership struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Team struct {
|
type Team struct {
|
||||||
ID, OrganizationID, Slug, Name string
|
ID, OrganizationID, Slug, Name, Status string
|
||||||
CreatedAt time.Time
|
Revision int64
|
||||||
|
CreatedAt, UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type TeamMembership struct {
|
type TeamMembership struct {
|
||||||
@@ -66,35 +78,74 @@ type ApplicationService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Invitation struct {
|
type Invitation struct {
|
||||||
Digest [32]byte
|
ID string
|
||||||
OrganizationID string
|
Digest [32]byte
|
||||||
Email, InvitedByUserID string
|
OrganizationID string
|
||||||
CreatedAt, ExpiresAt, UsedAt time.Time
|
Email, InvitedByUserID string
|
||||||
|
// DirectRole is the legacy single-role form. Use exactly one form.
|
||||||
|
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
|
||||||
|
CreatedAt, ExpiresAt, UsedAt, RevokedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditEvent struct {
|
||||||
|
ID, OrganizationID, ActorUserID, Action, ResourceType, ResourceID, RequestID, Summary string
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
CreateOrganization(context.Context, Organization, Membership) error
|
CreateOrganization(context.Context, Organization, Membership, AuditEvent) error
|
||||||
CreateTeam(context.Context, Team) error
|
OrganizationByID(context.Context, string) (Organization, error)
|
||||||
AddTeamMember(context.Context, TeamMembership) error
|
UpdateOrganization(context.Context, Organization, int64, AuditEvent) error
|
||||||
|
CreateTeam(context.Context, Team, AuditEvent) error
|
||||||
|
TeamByID(context.Context, string, string) (Team, error)
|
||||||
|
UpdateTeam(context.Context, Team, int64, AuditEvent) error
|
||||||
|
AddTeamMember(context.Context, TeamMembership, AuditEvent) error
|
||||||
|
RemoveTeamMember(context.Context, string, string, AuditEvent) error
|
||||||
|
SetMembershipStatus(context.Context, string, string, string, string, AuditEvent) error
|
||||||
|
RemoveMembership(context.Context, string, string, string, AuditEvent) error
|
||||||
CreateProject(context.Context, Project) error
|
CreateProject(context.Context, Project) error
|
||||||
CreateEnvironment(context.Context, Environment) error
|
CreateEnvironment(context.Context, Environment) error
|
||||||
CreateApplicationService(context.Context, ApplicationService) error
|
CreateApplicationService(context.Context, ApplicationService) error
|
||||||
CreateInvitation(context.Context, Invitation) error
|
CreateInvitation(context.Context, Invitation, string, AuditEvent) error
|
||||||
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
|
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
|
||||||
AcceptInvitation(context.Context, [32]byte, string, time.Time) error
|
Invitations(context.Context, string, int) ([]Invitation, error)
|
||||||
|
RevokeInvitation(context.Context, string, 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)
|
MembershipsForUser(context.Context, string) ([]Membership, error)
|
||||||
TeamsForUser(context.Context, string, string) ([]Team, error)
|
TeamsForUser(context.Context, string, string) ([]Team, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OptimisticMembershipRepository is implemented by repositories that can
|
||||||
|
// bind a membership lifecycle mutation to the exact state authorized by the
|
||||||
|
// caller. Services deliberately do not fall back to the older lifecycle
|
||||||
|
// methods: a stale fresh-authentication ceremony must fail instead of acting
|
||||||
|
// on a membership that changed while the ceremony was in progress.
|
||||||
|
type OptimisticMembershipRepository interface {
|
||||||
|
ChangeMembershipStatus(context.Context, MembershipStatusChange, string, AuditEvent) error
|
||||||
|
RemoveMembershipIfCurrent(context.Context, MembershipRemoval, string, AuditEvent) error
|
||||||
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Random io.Reader
|
Random io.Reader
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
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 {
|
||||||
repository Repository
|
repository Repository
|
||||||
random io.Reader
|
random io.Reader
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
ownerRole string
|
||||||
|
ownerManagedInvitations bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(repository Repository, options Options) (*Service, error) {
|
func New(repository Repository, options Options) (*Service, error) {
|
||||||
@@ -107,33 +158,49 @@ func New(repository Repository, options Options) (*Service, error) {
|
|||||||
if options.Now == nil {
|
if options.Now == nil {
|
||||||
options.Now = time.Now
|
options.Now = time.Now
|
||||||
}
|
}
|
||||||
return &Service{repository: repository, random: options.Random, now: options.Now}, nil
|
if options.OwnerRole != "" && !safeNamePattern.MatchString(options.OwnerRole) || options.OwnerManagedInvitations && options.OwnerRole == "" {
|
||||||
|
return nil, errors.New("organizations: owner role is invalid")
|
||||||
|
}
|
||||||
|
return &Service{repository: repository, random: options.Random, now: options.Now, ownerRole: options.OwnerRole, ownerManagedInvitations: options.OwnerManagedInvitations}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateOrganization struct {
|
type CreateOrganization struct {
|
||||||
Slug, Name, OwnerUserID string
|
Slug, Name, OwnerUserID string
|
||||||
Personal bool
|
Personal bool
|
||||||
|
RequestID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
|
func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
|
||||||
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
|
organization, owner, audit, err := service.prepareOrganization(input)
|
||||||
input.Name = strings.TrimSpace(input.Name)
|
|
||||||
if !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || !idPattern.MatchString(input.OwnerUserID) {
|
|
||||||
return Organization{}, errors.New("organizations: invalid organization")
|
|
||||||
}
|
|
||||||
id, err := token(service.random, 18)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Organization{}, err
|
return Organization{}, err
|
||||||
}
|
}
|
||||||
now := service.now().UTC()
|
if err = service.repository.CreateOrganization(ctx, organization, owner, audit); err != nil {
|
||||||
organization := Organization{ID: id, Slug: input.Slug, Name: input.Name, Personal: input.Personal, CreatedAt: now}
|
|
||||||
owner := Membership{OrganizationID: id, UserID: input.OwnerUserID, Status: "active", JoinedAt: now}
|
|
||||||
if err = service.repository.CreateOrganization(ctx, organization, owner); err != nil {
|
|
||||||
return Organization{}, err
|
return Organization{}, err
|
||||||
}
|
}
|
||||||
return organization, nil
|
return organization, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) prepareOrganization(input CreateOrganization) (Organization, Membership, AuditEvent, error) {
|
||||||
|
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
|
||||||
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
|
if !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || !idPattern.MatchString(input.OwnerUserID) || !boundedOptional(input.RequestID, 128) {
|
||||||
|
return Organization{}, Membership{}, AuditEvent{}, errors.New("organizations: invalid organization")
|
||||||
|
}
|
||||||
|
id, err := token(service.random, 18)
|
||||||
|
if err != nil {
|
||||||
|
return Organization{}, Membership{}, AuditEvent{}, err
|
||||||
|
}
|
||||||
|
now := service.now().UTC()
|
||||||
|
organization := Organization{ID: id, Slug: input.Slug, Name: input.Name, Status: "active", Personal: input.Personal, Revision: 1, CreatedAt: now, UpdatedAt: now}
|
||||||
|
owner := Membership{OrganizationID: id, UserID: input.OwnerUserID, Status: "active", JoinedAt: now}
|
||||||
|
audit, err := service.auditWithRequest(input.OwnerUserID, id, "organization.create", "organization", id, input.RequestID, "Organization created")
|
||||||
|
if err != nil {
|
||||||
|
return Organization{}, Membership{}, AuditEvent{}, err
|
||||||
|
}
|
||||||
|
return organization, owner, audit, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (service *Service) CreatePersonalOrganization(ctx context.Context, userID, displayName string) (Organization, error) {
|
func (service *Service) CreatePersonalOrganization(ctx context.Context, userID, displayName string) (Organization, error) {
|
||||||
value := make([]byte, 6)
|
value := make([]byte, 6)
|
||||||
if _, err := io.ReadFull(service.random, value); err != nil {
|
if _, err := io.ReadFull(service.random, value); err != nil {
|
||||||
@@ -143,30 +210,43 @@ func (service *Service) CreatePersonalOrganization(ctx context.Context, userID,
|
|||||||
return service.CreateOrganization(ctx, CreateOrganization{Slug: "personal-" + strings.ToLower(suffix), Name: strings.TrimSpace(displayName) + " — Personal", OwnerUserID: userID, Personal: true})
|
return service.CreateOrganization(ctx, CreateOrganization{Slug: "personal-" + strings.ToLower(suffix), Name: strings.TrimSpace(displayName) + " — Personal", OwnerUserID: userID, Personal: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateTeam struct{ OrganizationID, Slug, Name string }
|
type CreateTeam struct{ OrganizationID, Slug, Name, ActorUserID string }
|
||||||
|
|
||||||
func (service *Service) CreateTeam(ctx context.Context, input CreateTeam) (Team, error) {
|
func (service *Service) CreateTeam(ctx context.Context, input CreateTeam) (Team, error) {
|
||||||
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
|
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
|
||||||
input.Name = strings.TrimSpace(input.Name)
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
if !idPattern.MatchString(input.OrganizationID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
|
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.ActorUserID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
|
||||||
return Team{}, errors.New("organizations: invalid team")
|
return Team{}, errors.New("organizations: invalid team")
|
||||||
}
|
}
|
||||||
id, err := token(service.random, 18)
|
id, err := token(service.random, 18)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Team{}, err
|
return Team{}, err
|
||||||
}
|
}
|
||||||
team := Team{ID: id, OrganizationID: input.OrganizationID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
|
now := service.now().UTC()
|
||||||
if err = service.repository.CreateTeam(ctx, team); err != nil {
|
team := Team{ID: id, OrganizationID: input.OrganizationID, Slug: input.Slug, Name: input.Name, Status: "active", Revision: 1, CreatedAt: now, UpdatedAt: now}
|
||||||
|
audit, err := service.audit(input.ActorUserID, input.OrganizationID, "team.create", "team", id, "Team created")
|
||||||
|
if err != nil {
|
||||||
|
return Team{}, err
|
||||||
|
}
|
||||||
|
if err = service.repository.CreateTeam(ctx, team, audit); err != nil {
|
||||||
return Team{}, err
|
return Team{}, err
|
||||||
}
|
}
|
||||||
return team, nil
|
return team, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) AddTeamMember(ctx context.Context, teamID, userID string) error {
|
func (service *Service) AddTeamMember(ctx context.Context, teamID, userID, actorUserID string) error {
|
||||||
if !idPattern.MatchString(teamID) || !idPattern.MatchString(userID) {
|
if !idPattern.MatchString(teamID) || !idPattern.MatchString(userID) || !idPattern.MatchString(actorUserID) {
|
||||||
return errors.New("organizations: invalid team membership")
|
return errors.New("organizations: invalid team membership")
|
||||||
}
|
}
|
||||||
return service.repository.AddTeamMember(ctx, TeamMembership{TeamID: teamID, UserID: userID, JoinedAt: service.now().UTC()})
|
team, err := service.repository.TeamByID(ctx, "", teamID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
audit, err := service.audit(actorUserID, team.OrganizationID, "team.member.add", "team", teamID, "Team member added")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return service.repository.AddTeamMember(ctx, TeamMembership{TeamID: teamID, UserID: userID, JoinedAt: service.now().UTC()}, audit)
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateProject struct{ OrganizationID, Slug, Name string }
|
type CreateProject struct{ OrganizationID, Slug, Name string }
|
||||||
@@ -224,17 +304,58 @@ func (service *Service) CreateApplicationService(ctx context.Context, input Crea
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) Invite(ctx context.Context, organizationID, email, invitedBy string, lifetime time.Duration) (string, Invitation, error) {
|
func (service *Service) Invite(ctx context.Context, organizationID, email, invitedBy string, lifetime time.Duration) (string, Invitation, error) {
|
||||||
|
return service.InviteWithAccess(ctx, InviteWithAccess{OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, Lifetime: lifetime})
|
||||||
|
}
|
||||||
|
|
||||||
|
type InviteWithAccess struct {
|
||||||
|
OrganizationID, Email, InvitedByUserID, DirectRole string
|
||||||
|
DirectRoles []string
|
||||||
|
RequestID string
|
||||||
|
TeamIDs []string
|
||||||
|
Lifetime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAccess) (string, Invitation, error) {
|
||||||
|
organizationID, email, invitedBy, lifetime := input.OrganizationID, input.Email, input.InvitedByUserID, input.Lifetime
|
||||||
email = strings.ToLower(strings.TrimSpace(email))
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
|
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) || !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)
|
||||||
|
if err != nil {
|
||||||
|
return "", Invitation{}, err
|
||||||
|
}
|
||||||
raw, err := token(service.random, 32)
|
raw, err := token(service.random, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", Invitation{}, err
|
return "", Invitation{}, err
|
||||||
}
|
}
|
||||||
now := service.now().UTC()
|
now := service.now().UTC()
|
||||||
invitation := Invitation{Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, 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)}
|
||||||
if err = service.repository.CreateInvitation(ctx, invitation); err != nil {
|
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 {
|
||||||
|
return "", Invitation{}, err
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -246,10 +367,21 @@ func (service *Service) AcceptInvitation(ctx context.Context, rawToken, userID s
|
|||||||
}
|
}
|
||||||
digest := sha256.Sum256([]byte(rawToken))
|
digest := sha256.Sum256([]byte(rawToken))
|
||||||
now := service.now().UTC()
|
now := service.now().UTC()
|
||||||
if _, err := service.repository.InvitationByDigest(ctx, digest, now); err != nil {
|
invitation, err := service.repository.InvitationByDigest(ctx, digest, now)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return service.repository.AcceptInvitation(ctx, digest, userID, now)
|
audit, err := service.audit(userID, invitation.OrganizationID, "invitation.accept", "invitation", invitation.ID, "Organization invitation accepted")
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) Memberships(ctx context.Context, userID string) ([]Membership, error) {
|
func (service *Service) Memberships(ctx context.Context, userID string) ([]Membership, error) {
|
||||||
@@ -259,6 +391,16 @@ func (service *Service) Memberships(ctx context.Context, userID string) ([]Membe
|
|||||||
return service.repository.MembershipsForUser(ctx, userID)
|
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) {
|
func (service *Service) Teams(ctx context.Context, organizationID, userID string) ([]Team, error) {
|
||||||
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) {
|
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) {
|
||||||
return nil, errors.New("organizations: invalid team query")
|
return nil, errors.New("organizations: invalid team query")
|
||||||
@@ -266,8 +408,262 @@ func (service *Service) Teams(ctx context.Context, organizationID, userID string
|
|||||||
return service.repository.TeamsForUser(ctx, organizationID, userID)
|
return service.repository.TeamsForUser(ctx, organizationID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateOrganization struct {
|
||||||
|
ID, Slug, Name, ActorUserID, RequestID string
|
||||||
|
ExpectedRevision int64
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
value, err := service.repository.OrganizationByID(ctx, input.ID)
|
||||||
|
if err != nil {
|
||||||
|
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()
|
||||||
|
audit, err := service.auditWithRequest(input.ActorUserID, value.ID, "organization.update", "organization", value.ID, input.RequestID, "Organization details updated")
|
||||||
|
if err != nil {
|
||||||
|
return Organization{}, err
|
||||||
|
}
|
||||||
|
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 value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetOrganizationStatus struct {
|
||||||
|
ID, Status, ActorUserID, RequestID string
|
||||||
|
ExpectedRevision int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) SetOrganizationStatus(ctx context.Context, input SetOrganizationStatus) (Organization, error) {
|
||||||
|
if !idPattern.MatchString(input.ID) || !idPattern.MatchString(input.ActorUserID) || (input.Status != "active" && input.Status != "archived") || input.ExpectedRevision < 1 || !boundedOptional(input.RequestID, 128) {
|
||||||
|
return Organization{}, errors.New("organizations: invalid organization status")
|
||||||
|
}
|
||||||
|
value, err := service.repository.OrganizationByID(ctx, input.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Organization{}, err
|
||||||
|
}
|
||||||
|
if value.Personal {
|
||||||
|
return Organization{}, ErrPersonalOrganization
|
||||||
|
}
|
||||||
|
value.Status, value.Revision, value.UpdatedAt = input.Status, input.ExpectedRevision+1, service.now().UTC()
|
||||||
|
action := "organization.archive"
|
||||||
|
summary := "Organization archived"
|
||||||
|
if input.Status == "active" {
|
||||||
|
action, summary = "organization.reactivate", "Organization reactivated"
|
||||||
|
}
|
||||||
|
audit, err := service.auditWithRequest(input.ActorUserID, value.ID, action, "organization", value.ID, input.RequestID, summary)
|
||||||
|
if err != nil {
|
||||||
|
return Organization{}, err
|
||||||
|
}
|
||||||
|
if err = service.repository.UpdateOrganization(ctx, value, input.ExpectedRevision, audit); err != nil {
|
||||||
|
return Organization{}, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateTeam struct {
|
||||||
|
OrganizationID, ID, Slug, Name, Status, ActorUserID, RequestID string
|
||||||
|
ExpectedRevision int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) UpdateTeam(ctx context.Context, input UpdateTeam) (Team, error) {
|
||||||
|
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
|
||||||
|
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.ID) || !idPattern.MatchString(input.ActorUserID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || (input.Status != "active" && input.Status != "archived") || input.ExpectedRevision < 1 || !boundedOptional(input.RequestID, 128) {
|
||||||
|
return Team{}, errors.New("organizations: invalid team update")
|
||||||
|
}
|
||||||
|
value, err := service.repository.TeamByID(ctx, input.OrganizationID, input.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Team{}, err
|
||||||
|
}
|
||||||
|
priorStatus := value.Status
|
||||||
|
value.Slug, value.Name, value.Status, value.Revision, value.UpdatedAt = input.Slug, input.Name, input.Status, input.ExpectedRevision+1, service.now().UTC()
|
||||||
|
action, summary := "team.update", "Team details updated"
|
||||||
|
if input.Status != priorStatus {
|
||||||
|
action, summary = "team.archive", "Team archived"
|
||||||
|
if input.Status == "active" {
|
||||||
|
action, summary = "team.reactivate", "Team reactivated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audit, err := service.auditWithRequest(input.ActorUserID, value.OrganizationID, action, "team", value.ID, input.RequestID, summary)
|
||||||
|
if err != nil {
|
||||||
|
return Team{}, err
|
||||||
|
}
|
||||||
|
if err = service.repository.UpdateTeam(ctx, value, input.ExpectedRevision, audit); err != nil {
|
||||||
|
return Team{}, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) RemoveTeamMember(ctx context.Context, organizationID, teamID, userID, actorUserID, requestID string) error {
|
||||||
|
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(teamID) || !idPattern.MatchString(userID) || !idPattern.MatchString(actorUserID) || !boundedOptional(requestID, 128) {
|
||||||
|
return errors.New("organizations: invalid team membership removal")
|
||||||
|
}
|
||||||
|
audit, err := service.auditWithRequest(actorUserID, organizationID, "team.member.remove", "team", teamID, requestID, "Team member removed")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return service.repository.RemoveTeamMember(ctx, teamID, userID, audit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) SetMembershipStatus(ctx context.Context, organizationID, userID, status, actorUserID, requestID string) error {
|
||||||
|
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) || !idPattern.MatchString(actorUserID) || (status != "active" && status != "suspended") || !boundedOptional(requestID, 128) {
|
||||||
|
return errors.New("organizations: invalid membership status")
|
||||||
|
}
|
||||||
|
if status != "active" && service.ownerRole == "" {
|
||||||
|
return errors.New("organizations: owner role is required for membership lifecycle changes")
|
||||||
|
}
|
||||||
|
audit, err := service.auditWithRequest(actorUserID, organizationID, "membership."+status, "membership", userID, requestID, "Organization membership set to "+status)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return service.repository.SetMembershipStatus(ctx, organizationID, userID, status, service.ownerRole, audit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MembershipStatusChange describes an exact active-to-suspended or
|
||||||
|
// suspended-to-active transition. ExpectedStatus is part of the authorized
|
||||||
|
// operation and is checked again inside the repository transaction.
|
||||||
|
type MembershipStatusChange struct {
|
||||||
|
OrganizationID, UserID, ExpectedStatus, Status, ActorUserID, RequestID string
|
||||||
|
}
|
||||||
|
|
||||||
|
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) ||
|
||||||
|
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") ||
|
||||||
|
(input.Status != "active" && input.Status != "suspended") || input.Status == input.ExpectedStatus ||
|
||||||
|
!boundedOptional(input.RequestID, 128) {
|
||||||
|
return errors.New("organizations: invalid membership status change")
|
||||||
|
}
|
||||||
|
if service.ownerRole == "" {
|
||||||
|
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)
|
||||||
|
if !requireOwner && !ok {
|
||||||
|
return ErrMembershipLifecycleUnsupported
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if requireOwner {
|
||||||
|
return ownedRepository.ChangeOwnedMembershipStatus(ctx, input, service.ownerRole, audit)
|
||||||
|
}
|
||||||
|
return repository.ChangeMembershipStatus(ctx, input, service.ownerRole, audit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) RemoveMembership(ctx context.Context, organizationID, userID, actorUserID, requestID string) error {
|
||||||
|
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) || !idPattern.MatchString(actorUserID) || !boundedOptional(requestID, 128) {
|
||||||
|
return errors.New("organizations: invalid membership removal")
|
||||||
|
}
|
||||||
|
if service.ownerRole == "" {
|
||||||
|
return errors.New("organizations: owner role is required for membership lifecycle changes")
|
||||||
|
}
|
||||||
|
audit, err := service.auditWithRequest(actorUserID, organizationID, "membership.remove", "membership", userID, requestID, "Organization membership removed")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return service.repository.RemoveMembership(ctx, organizationID, userID, service.ownerRole, audit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MembershipRemoval binds removal to the exact membership state observed by
|
||||||
|
// the caller before fresh authentication began.
|
||||||
|
type MembershipRemoval struct {
|
||||||
|
OrganizationID, UserID, ExpectedStatus, ActorUserID, RequestID string
|
||||||
|
}
|
||||||
|
|
||||||
|
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) ||
|
||||||
|
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || !boundedOptional(input.RequestID, 128) {
|
||||||
|
return errors.New("organizations: invalid membership removal")
|
||||||
|
}
|
||||||
|
if service.ownerRole == "" {
|
||||||
|
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)
|
||||||
|
if !requireOwner && !ok {
|
||||||
|
return ErrMembershipLifecycleUnsupported
|
||||||
|
}
|
||||||
|
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership.remove", "membership", input.UserID, input.RequestID, "Organization membership removed")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if requireOwner {
|
||||||
|
return ownedRepository.RemoveOwnedMembershipIfCurrent(ctx, input, service.ownerRole, audit)
|
||||||
|
}
|
||||||
|
return repository.RemoveMembershipIfCurrent(ctx, input, service.ownerRole, audit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) Invitations(ctx context.Context, organizationID string, limit int) ([]Invitation, error) {
|
||||||
|
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 1000 {
|
||||||
|
return nil, errors.New("organizations: invalid invitation query")
|
||||||
|
}
|
||||||
|
return service.repository.Invitations(ctx, organizationID, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) RevokeInvitation(ctx context.Context, organizationID, invitationID, actorUserID, requestID string) error {
|
||||||
|
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitationID) || !idPattern.MatchString(actorUserID) || !boundedOptional(requestID, 128) {
|
||||||
|
return errors.New("organizations: invalid invitation revocation")
|
||||||
|
}
|
||||||
|
now := service.now().UTC()
|
||||||
|
audit, err := service.auditWithRequest(actorUserID, organizationID, "invitation.revoke", "invitation", invitationID, requestID, "Organization invitation revoked")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return service.repository.RevokeInvitation(ctx, organizationID, invitationID, service.ownerRole, now, audit)
|
||||||
|
}
|
||||||
|
|
||||||
func (service *Service) Repository() Repository { return service.repository }
|
func (service *Service) Repository() Repository { return service.repository }
|
||||||
|
|
||||||
|
func (service *Service) audit(actor, organizationID, action, resourceType, resourceID, summary string) (AuditEvent, error) {
|
||||||
|
return service.auditWithRequest(actor, organizationID, action, resourceType, resourceID, "", summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) auditWithRequest(actor, organizationID, action, resourceType, resourceID, requestID, summary string) (AuditEvent, error) {
|
||||||
|
if !idPattern.MatchString(actor) || !idPattern.MatchString(organizationID) || !bounded(action, 128) || !bounded(resourceType, 128) || !bounded(resourceID, 128) || !boundedOptional(requestID, 128) || !bounded(summary, 512) {
|
||||||
|
return AuditEvent{}, errors.New("organizations: invalid audit event")
|
||||||
|
}
|
||||||
|
id, err := token(service.random, 18)
|
||||||
|
if err != nil {
|
||||||
|
return AuditEvent{}, err
|
||||||
|
}
|
||||||
|
return AuditEvent{ID: id, OrganizationID: organizationID, ActorUserID: actor, Action: action, ResourceType: resourceType, ResourceID: resourceID, RequestID: requestID, Summary: summary, CreatedAt: service.now().UTC()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func token(random io.Reader, size int) (string, error) {
|
func token(random io.Reader, size int) (string, error) {
|
||||||
value := make([]byte, size)
|
value := make([]byte, size)
|
||||||
if _, err := io.ReadFull(random, value); err != nil {
|
if _, err := io.ReadFull(random, value); err != nil {
|
||||||
@@ -279,3 +675,26 @@ func token(random io.Reader, size int) (string, error) {
|
|||||||
func bounded(value string, limit int) bool {
|
func bounded(value string, limit int) bool {
|
||||||
return value != "" && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
|
return value != "" && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boundedOptional(value string, limit int) bool {
|
||||||
|
return len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validIDs(values []string, limit int) bool {
|
||||||
|
if len(values) > limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if !idPattern.MatchString(value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, exists := seen[value]; exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeNamePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
|
||||||
|
|||||||
@@ -47,25 +47,60 @@ func TestInvitationFailsClosed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOptimisticMembershipLifecycleFailsClosedWithoutRepositorySupport(t *testing.T) {
|
||||||
|
service, err := New(&repositoryStub{}, Options{OwnerRole: "organization.owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = service.ChangeMembershipStatus(t.Context(), MembershipStatusChange{OrganizationID: "organization-1234", UserID: "user-12345678", ExpectedStatus: "active", Status: "suspended", ActorUserID: "user-87654321", RequestID: "request-suspend"}); !errors.Is(err, ErrMembershipLifecycleUnsupported) {
|
||||||
|
t.Fatalf("status change err=%v", err)
|
||||||
|
}
|
||||||
|
if err = service.RemoveMembershipIfCurrent(t.Context(), MembershipRemoval{OrganizationID: "organization-1234", UserID: "user-12345678", ExpectedStatus: "active", ActorUserID: "user-87654321", RequestID: "request-remove"}); !errors.Is(err, ErrMembershipLifecycleUnsupported) {
|
||||||
|
t.Fatalf("removal err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type repositoryStub struct {
|
type repositoryStub struct {
|
||||||
organization Organization
|
organization Organization
|
||||||
invitation Invitation
|
invitation Invitation
|
||||||
invitationErr error
|
invitationErr error
|
||||||
acceptedUser string
|
acceptedUser string
|
||||||
|
members []Membership
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership) error {
|
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership, _ AuditEvent) error {
|
||||||
repository.organization = organization
|
repository.organization = organization
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (*repositoryStub) CreateTeam(context.Context, Team) error { return nil }
|
func (repository *repositoryStub) OrganizationByID(context.Context, string) (Organization, error) {
|
||||||
func (*repositoryStub) AddTeamMember(context.Context, TeamMembership) error { return nil }
|
return repository.organization, nil
|
||||||
|
}
|
||||||
|
func (*repositoryStub) UpdateOrganization(context.Context, Organization, int64, AuditEvent) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (*repositoryStub) CreateTeam(context.Context, Team, AuditEvent) error { return nil }
|
||||||
|
func (*repositoryStub) TeamByID(context.Context, string, string) (Team, error) {
|
||||||
|
return Team{}, nil
|
||||||
|
}
|
||||||
|
func (*repositoryStub) UpdateTeam(context.Context, Team, int64, AuditEvent) error { return nil }
|
||||||
|
func (*repositoryStub) AddTeamMember(context.Context, TeamMembership, AuditEvent) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (*repositoryStub) RemoveTeamMember(context.Context, string, string, AuditEvent) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (*repositoryStub) SetMembershipStatus(context.Context, string, string, string, string, AuditEvent) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (*repositoryStub) RemoveMembership(context.Context, string, string, string, AuditEvent) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (*repositoryStub) CreateProject(context.Context, Project) error { return nil }
|
func (*repositoryStub) CreateProject(context.Context, Project) error { return nil }
|
||||||
func (*repositoryStub) CreateEnvironment(context.Context, Environment) error { return nil }
|
func (*repositoryStub) CreateEnvironment(context.Context, Environment) error { return nil }
|
||||||
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
|
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation) error {
|
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ string, _ AuditEvent) error {
|
||||||
repository.invitation = invitation
|
repository.invitation = invitation
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -75,11 +110,20 @@ func (repository *repositoryStub) InvitationByDigest(context.Context, [32]byte,
|
|||||||
}
|
}
|
||||||
return repository.invitation, nil
|
return repository.invitation, nil
|
||||||
}
|
}
|
||||||
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time) error {
|
func (*repositoryStub) Invitations(context.Context, string, int) ([]Invitation, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*repositoryStub) RevokeInvitation(context.Context, string, string, string, time.Time, AuditEvent) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time, _ AuditEvent) error {
|
||||||
repository.acceptedUser = userID
|
repository.acceptedUser = userID
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (*repositoryStub) MembershipsForUser(context.Context, string) ([]Membership, error) {
|
func (*repositoryStub) MembershipsForUser(context.Context, string) ([]Membership, error) {
|
||||||
return nil, nil
|
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 }
|
func (*repositoryStub) TeamsForUser(context.Context, string, string) ([]Team, error) { return nil, nil }
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package organizations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OwnedOrganization is one atomic creation command. Implementations must commit
|
||||||
|
// the organization, membership, direct owner binding, and both audits together.
|
||||||
|
type OwnedOrganization struct {
|
||||||
|
Organization Organization
|
||||||
|
Membership Membership
|
||||||
|
OwnerBinding access.Binding
|
||||||
|
OrganizationAudit AuditEvent
|
||||||
|
AccessAudit access.AuditEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnedOrganizationRepository extends Repository without changing the legacy
|
||||||
|
// membership-only CreateOrganization contract. There is no non-atomic fallback.
|
||||||
|
type OwnedOrganizationRepository interface {
|
||||||
|
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
|
||||||
|
// inside the creation transaction. Applications authorize creation and choose
|
||||||
|
// OwnerRole when constructing the service, never from a submitted role name.
|
||||||
|
// The role must already be seeded in the repository.
|
||||||
|
func (service *Service) CreateOwnedOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
|
||||||
|
if service.ownerRole == "" {
|
||||||
|
return Organization{}, errors.New("organizations: owned creation requires a configured owner role")
|
||||||
|
}
|
||||||
|
repository, ok := service.repository.(OwnedOrganizationRepository)
|
||||||
|
if !ok {
|
||||||
|
return Organization{}, ErrOwnedCreationUnsupported
|
||||||
|
}
|
||||||
|
organization, membership, audit, err := service.prepareOrganization(input)
|
||||||
|
if err != nil {
|
||||||
|
return Organization{}, err
|
||||||
|
}
|
||||||
|
bindingID, err := token(service.random, 18)
|
||||||
|
if err != nil {
|
||||||
|
return Organization{}, err
|
||||||
|
}
|
||||||
|
accessAuditID, err := token(service.random, 18)
|
||||||
|
if err != nil {
|
||||||
|
return Organization{}, err
|
||||||
|
}
|
||||||
|
binding := access.Binding{
|
||||||
|
ID: bindingID, SubjectKind: access.User, SubjectID: input.OwnerUserID,
|
||||||
|
Role: service.ownerRole, Scope: access.Scope{OrganizationID: organization.ID},
|
||||||
|
GrantedBy: input.OwnerUserID, GrantedAt: organization.CreatedAt,
|
||||||
|
}
|
||||||
|
accessAudit := access.AuditEvent{
|
||||||
|
ID: accessAuditID, OrganizationID: organization.ID, ActorUserID: input.OwnerUserID,
|
||||||
|
Action: "access.binding.grant", ResourceType: "binding", ResourceID: bindingID,
|
||||||
|
RequestID: input.RequestID, Summary: "Initial organization owner granted",
|
||||||
|
CreatedAt: organization.CreatedAt,
|
||||||
|
}
|
||||||
|
if err = repository.CreateOwnedOrganization(ctx, OwnedOrganization{
|
||||||
|
Organization: organization, Membership: membership, OwnerBinding: binding,
|
||||||
|
OrganizationAudit: audit, AccessAudit: accessAudit,
|
||||||
|
}); err != nil {
|
||||||
|
return Organization{}, err
|
||||||
|
}
|
||||||
|
return organization, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package organizations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ownedRepositoryStub struct {
|
||||||
|
repositoryStub
|
||||||
|
setup OwnedOrganization
|
||||||
|
calls int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repository *ownedRepositoryStub) CreateOwnedOrganization(_ context.Context, setup OwnedOrganization) error {
|
||||||
|
repository.calls++
|
||||||
|
repository.setup = setup
|
||||||
|
return repository.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedOrganizationUsesConfiguredRoleAndAtomicRepository(t *testing.T) {
|
||||||
|
now := time.Unix(1000, 0).UTC()
|
||||||
|
repository := &ownedRepositoryStub{}
|
||||||
|
service, err := New(repository, Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := CreateOrganization{Slug: " CLIENT-BUSINESS ", Name: " Client Business ", OwnerUserID: "customer-12345", RequestID: "request-creation"}
|
||||||
|
organization, err := service.CreateOwnedOrganization(t.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
setup := repository.setup
|
||||||
|
if repository.calls != 1 || repository.organization.ID != "" || setup.Organization != organization || organization.Slug != "client-business" || organization.Name != "Client Business" {
|
||||||
|
t.Fatalf("unexpected creation: %+v", setup)
|
||||||
|
}
|
||||||
|
if setup.Membership.UserID != input.OwnerUserID || setup.OwnerBinding.SubjectKind != access.User || setup.OwnerBinding.SubjectID != input.OwnerUserID || setup.OwnerBinding.Role != "customer.owner" || setup.OwnerBinding.Scope != (access.Scope{OrganizationID: organization.ID}) || setup.OwnerBinding.GrantedBy != input.OwnerUserID {
|
||||||
|
t.Fatalf("unexpected owner: %+v", setup)
|
||||||
|
}
|
||||||
|
if setup.OrganizationAudit.RequestID != input.RequestID || setup.AccessAudit.RequestID != input.RequestID || setup.AccessAudit.ResourceID != setup.OwnerBinding.ID || !setup.OwnerBinding.GrantedAt.Equal(now) {
|
||||||
|
t.Fatalf("unexpected audits: %+v", setup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedOrganizationFailsWithoutAtomicSupport(t *testing.T) {
|
||||||
|
repository := &repositoryStub{}
|
||||||
|
service, _ := New(repository, Options{OwnerRole: "customer.owner"})
|
||||||
|
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345"})
|
||||||
|
if !errors.Is(err, ErrOwnedCreationUnsupported) || organization.ID != "" || repository.organization.ID != "" {
|
||||||
|
t.Fatalf("non-atomic fallback: organization=%+v err=%v", organization, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedOrganizationRejectsInvalidSetupBeforeStorage(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
role string
|
||||||
|
request string
|
||||||
|
random string
|
||||||
|
}{
|
||||||
|
{name: "missing role", random: strings.Repeat("a", 200)},
|
||||||
|
{name: "bad request ID", role: "customer.owner", request: "request\nsecret", random: strings.Repeat("a", 200)},
|
||||||
|
{name: "random failure", role: "customer.owner", random: strings.Repeat("a", 40)},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
repository := &ownedRepositoryStub{}
|
||||||
|
service, err := New(repository, Options{OwnerRole: test.role, Random: strings.NewReader(test.random)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345", RequestID: test.request})
|
||||||
|
if err == nil || organization.ID != "" || repository.calls != 0 {
|
||||||
|
t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedOrganizationDoesNotReturnUncommittedIdentity(t *testing.T) {
|
||||||
|
want := errors.New("durability failure")
|
||||||
|
repository := &ownedRepositoryStub{err: want}
|
||||||
|
service, _ := New(repository, Options{OwnerRole: "customer.owner"})
|
||||||
|
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345"})
|
||||||
|
if !errors.Is(err, want) || organization.ID != "" || repository.calls != 1 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@
|
|||||||
package requestlog
|
package requestlog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -208,3 +210,17 @@ func (capture *responseCapture) Write(body []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (capture *responseCapture) Unwrap() http.ResponseWriter { return capture.ResponseWriter }
|
func (capture *responseCapture) Unwrap() http.ResponseWriter { return capture.ResponseWriter }
|
||||||
|
|
||||||
|
// Hijack preserves connection-upgrade support through the request evidence
|
||||||
|
// wrapper. A successful upgrade is recorded as HTTP 101; bytes exchanged after
|
||||||
|
// hijacking belong to the upgraded protocol and are intentionally not counted
|
||||||
|
// as HTTP response-body bytes.
|
||||||
|
func (capture *responseCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
connection, buffer, err := http.NewResponseController(capture.ResponseWriter).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
capture.wroteHeader = true
|
||||||
|
capture.status = http.StatusSwitchingProtocols
|
||||||
|
return connection, buffer, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
package requestlog
|
package requestlog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -24,6 +26,16 @@ type memorySink struct {
|
|||||||
ctxErr error
|
ctxErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type hijackableRecorder struct {
|
||||||
|
*httptest.ResponseRecorder
|
||||||
|
connection net.Conn
|
||||||
|
buffer *bufio.ReadWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (recorder *hijackableRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
return recorder.connection, recorder.buffer, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (sink *memorySink) WriteRecord(ctx context.Context, record Record) error {
|
func (sink *memorySink) WriteRecord(ctx context.Context, record Record) error {
|
||||||
sink.records = append(sink.records, record)
|
sink.records = append(sink.records, record)
|
||||||
sink.ctxErr = ctx.Err()
|
sink.ctxErr = ctx.Err()
|
||||||
@@ -208,3 +220,29 @@ func TestResponseStatusUsesFirstHeader(t *testing.T) {
|
|||||||
t.Fatalf("status=%d", sink.records[0].Status)
|
t.Fatalf("status=%d", sink.records[0].Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResponseCapturePreservesConnectionHijacking(t *testing.T) {
|
||||||
|
serverConnection, clientConnection := net.Pipe()
|
||||||
|
defer serverConnection.Close()
|
||||||
|
defer clientConnection.Close()
|
||||||
|
underlying := &hijackableRecorder{
|
||||||
|
ResponseRecorder: httptest.NewRecorder(),
|
||||||
|
connection: serverConnection,
|
||||||
|
buffer: bufio.NewReadWriter(bufio.NewReader(serverConnection), bufio.NewWriter(serverConnection)),
|
||||||
|
}
|
||||||
|
capture := &responseCapture{ResponseWriter: underlying, status: http.StatusOK}
|
||||||
|
hijacker, ok := any(capture).(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("request evidence wrapper does not expose http.Hijacker")
|
||||||
|
}
|
||||||
|
connection, buffer, err := hijacker.Hijack()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if connection != serverConnection || buffer != underlying.buffer {
|
||||||
|
t.Fatal("hijacked connection was not passed through")
|
||||||
|
}
|
||||||
|
if capture.status != http.StatusSwitchingProtocols || !capture.wroteHeader || capture.bytes != 0 {
|
||||||
|
t.Fatalf("capture after hijack=%+v", capture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
.gitea/workflows/verify.yml
|
.gitea/workflows/verify.yml
|
||||||
.gitignore
|
.gitignore
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
|
AI_DISCLOSURE.md
|
||||||
CONTRIBUTING.md
|
CONTRIBUTING.md
|
||||||
|
LICENSE
|
||||||
LICENSES.md
|
LICENSES.md
|
||||||
LICENSES/0BSD.txt
|
LICENSES/0BSD.txt
|
||||||
LICENSES/AGPL-3.0-only.txt
|
LICENSES/AGPL-3.0-only.txt
|
||||||
@@ -16,16 +18,23 @@ SECURITY.md
|
|||||||
THIRD_PARTY_NOTICES.md
|
THIRD_PARTY_NOTICES.md
|
||||||
abuse/abuse.go
|
abuse/abuse.go
|
||||||
abuse/abuse_test.go
|
abuse/abuse_test.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/password.go
|
auth/password.go
|
||||||
auth/password_test.go
|
auth/password_test.go
|
||||||
|
authrecovery/recovery.go
|
||||||
|
authrecovery/recovery_test.go
|
||||||
|
authrecovery/assisted_test.go
|
||||||
auth/service_test.go
|
auth/service_test.go
|
||||||
authhttp/authhttp.go
|
authhttp/authhttp.go
|
||||||
authhttp/authhttp_test.go
|
authhttp/authhttp_test.go
|
||||||
@@ -33,18 +42,37 @@ authhttp/passkey.go
|
|||||||
authhttp/passkey_test.go
|
authhttp/passkey_test.go
|
||||||
authsqlite/store.go
|
authsqlite/store.go
|
||||||
authsqlite/store_test.go
|
authsqlite/store_test.go
|
||||||
|
authsqlite/directory.go
|
||||||
|
authsqlite/directory_test.go
|
||||||
|
authsqlite/account.go
|
||||||
|
authsqlite/account_test.go
|
||||||
authsqlite/access.go
|
authsqlite/access.go
|
||||||
|
authsqlite/assisted_recovery.go
|
||||||
|
authsqlite/bootstrap.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
|
||||||
authwebauthn/fuzz_test.go
|
authwebauthn/fuzz_test.go
|
||||||
authwebauthn/service.go
|
authwebauthn/service.go
|
||||||
authwebauthn/service_test.go
|
authwebauthn/service_test.go
|
||||||
authwebauthn/types.go
|
authwebauthn/types.go
|
||||||
|
bootstrap/bootstrap.go
|
||||||
|
bootstrap/bootstrap_test.go
|
||||||
|
media/media.go
|
||||||
|
media/media_test.go
|
||||||
|
medialocal/store.go
|
||||||
|
medialocal/store_test.go
|
||||||
internal/webauthnvendored/
|
internal/webauthnvendored/
|
||||||
docs/ADOPTION.md
|
docs/ADOPTION.md
|
||||||
docs/ARCHITECTURE.md
|
docs/ARCHITECTURE.md
|
||||||
docs/DEPENDENCIES.md
|
docs/DEPENDENCIES.md
|
||||||
|
docs/DOGFOOD.md
|
||||||
docs/GETTING_STARTED.md
|
docs/GETTING_STARTED.md
|
||||||
docs/MODULES.md
|
docs/MODULES.md
|
||||||
docs/ORGANIZATIONS.md
|
docs/ORGANIZATIONS.md
|
||||||
@@ -53,6 +81,8 @@ docs/PUBLIC_SNAPSHOT.md
|
|||||||
docs/SANDWICH_HIME.md
|
docs/SANDWICH_HIME.md
|
||||||
docs/SERVICES_ROADMAP.md
|
docs/SERVICES_ROADMAP.md
|
||||||
docs/THREAT_MODEL.md
|
docs/THREAT_MODEL.md
|
||||||
|
doc.go
|
||||||
|
example_test.go
|
||||||
go.mod
|
go.mod
|
||||||
go.sum
|
go.sum
|
||||||
requestlog/jsonl.go
|
requestlog/jsonl.go
|
||||||
@@ -63,6 +93,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