Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f54c75501 | ||
|
|
277cffed8c | ||
|
|
92ef63ba00 | ||
|
|
337b56ec1b | ||
|
|
7c8f0d708e | ||
|
|
a769d1ea7b | ||
|
|
a39e8f893c | ||
|
|
5563306368 |
@@ -2,7 +2,7 @@
|
||||
name: verify
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'codex/**']
|
||||
branches: [main, 'codex/**', 'gamertan/**']
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
+84
-1
@@ -2,7 +2,86 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 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
|
||||
|
||||
- Add an explicit mode-`0640` JSONL option for applications that authorize one
|
||||
narrowly scoped collector group, while keeping private mode `0600` as the
|
||||
default and rejecting permissive modes.
|
||||
- Document the setgid-directory ownership boundary for Observatory-style
|
||||
collection without granting the collector broader application access.
|
||||
- Make vendored dependency and public-snapshot verification portable across
|
||||
the maintained Linux gate and native macOS development environments.
|
||||
- Keep Previews 1–5 immutable; applications select Preview 6 explicitly when
|
||||
adopting collector-readable request evidence.
|
||||
|
||||
## v0.1.0-preview.5 — 2026-08-21
|
||||
|
||||
- Add storage-neutral passkey registration, discoverable login, and
|
||||
operation-bound fresh assertions without adding self-registration, password
|
||||
@@ -17,6 +96,10 @@
|
||||
- Pin WebAuthn protocol verification to `github.com/go-webauthn/webauthn`
|
||||
`v0.17.1` and record its source identity, module checksums, licence, and
|
||||
transitive security boundary.
|
||||
- Add self-service passkey enrollment and removal primitives with fresh
|
||||
assertion, session revocation, and last-credential protection.
|
||||
- Keep Previews 1–4 immutable; applications select Preview 5 explicitly when
|
||||
adopting the passkey boundary.
|
||||
|
||||
## v0.1.0-preview.4 — 2026-08-18
|
||||
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
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,147 @@
|
||||
|
||||
# Gamertan Web Foundations
|
||||
|
||||
> Status: `v0.1.0-preview.4` public preview. APIs may change before a stable
|
||||
> release; Linux is the maintained release platform.
|
||||
[](https://pkg.go.dev/gamertan.com/web)
|
||||
[](https://gitea.speelman.ca/gamertan/web/actions?workflow=verify.yml)
|
||||
|
||||
Small, composable Go packages for the unglamorous boundaries of a careful web
|
||||
application: request identity, structured request logs, browser security,
|
||||
passwords, passkeys, sessions, permissions, SQLite persistence, and private
|
||||
analytics.
|
||||
**Security-conscious building blocks for ordinary `net/http` applications.**
|
||||
|
||||
This is a toolkit, not an application framework. Your application keeps its
|
||||
router, HTTP policy, HTML, authorization decisions, cache behavior, and
|
||||
deployment. Each package works with `net/http` and can be adopted independently.
|
||||
Web Foundations provides small, composable Go packages for the unglamorous
|
||||
boundaries of a careful web application: request identity, structured request
|
||||
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
|
||||
Go binaries. It requires no Redis, message broker, hosted identity provider,
|
||||
telemetry service, or JavaScript framework.
|
||||
It is a toolkit, not an application framework. Your application keeps its
|
||||
router, handlers, HTML, authorization decisions, cache behavior, and
|
||||
deployment. Adopt one boundary at a time; Go compiles and links only the
|
||||
packages you import.
|
||||
|
||||
> **Public preview:** `v0.1.0-preview.12`. 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) |
|
||||
| Printable single-use recovery codes | [`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
|
||||
|
||||
Pin the preview in an application module, then import only the packages that
|
||||
application needs:
|
||||
Pin the preview in an application module:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web@v0.1.0-preview.4
|
||||
go get gamertan.com/web@v0.1.0-preview.12
|
||||
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
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.4
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.12
|
||||
```
|
||||
|
||||
The version belongs to the `gamertan.com/web` module. Go compiles and links
|
||||
only the packages the application imports. See the [getting-started guide](docs/GETTING_STARTED.md)
|
||||
and [module-boundary policy](docs/MODULES.md) before choosing a first slice.
|
||||
The version belongs to the `gamertan.com/web` module. See the
|
||||
[module-boundary policy](docs/MODULES.md) before selecting a first slice.
|
||||
|
||||
Canonical source, issues, security policy, and release notes live on
|
||||
[Gamertan Gitea](https://gitea.speelman.ca/gamertan/web). GitHub is a read-only
|
||||
discovery snapshot rather than a second release origin.
|
||||
## Compose a request path
|
||||
|
||||
Linux is the required and supported release platform. WSL may be used as a
|
||||
Linux development environment. Native Windows is not a release gate or support
|
||||
promise; downstream users may evaluate the ordinary Go packages elsewhere
|
||||
without turning that portability into a maintained compatibility claim.
|
||||
Build middleware from the application outward. The request metadata resolver
|
||||
is outermost so every package inside it observes the same request identity:
|
||||
|
||||
## Packages
|
||||
```text
|
||||
request
|
||||
└─ requestmeta ─ websec ─ requestlog ─ your router and handlers
|
||||
```
|
||||
|
||||
- [`requestmeta`](requestmeta): trusted-proxy resolution, HTTPS/origin metadata,
|
||||
and request IDs.
|
||||
- [`requestlog`](requestlog): bounded versioned records, middleware, sinks, and
|
||||
private JSONL.
|
||||
- [`websec`](websec): headers, origin checks, CSRF, redirects, body limits, and
|
||||
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.
|
||||
```go
|
||||
var handler http.Handler = router
|
||||
handler = requestlog.Middleware(sink, logPolicy)(handler)
|
||||
handler = websec.Headers(headerPolicy)(handler)
|
||||
handler = resolver.Middleware(handler)
|
||||
```
|
||||
|
||||
The copyable starter under `starters/basic` demonstrates the packages without
|
||||
turning them into a router or template system.
|
||||
The copyable [`starters/basic`](starters/basic) server demonstrates that
|
||||
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.
|
||||
- [`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
|
||||
|
||||
Web Foundations deliberately does not provide a template language. Sandwich
|
||||
Hime is the preferred companion for Gamertan applications that want HTML-first,
|
||||
typed, ahead-of-time Go templates. The two projects remain independently
|
||||
usable: this module does not import the `sando` runtime, and Sandwich Hime does
|
||||
not own middleware, authentication, logging, routing, or deployment.
|
||||
typed, ahead-of-time Go templates. The projects remain independently usable.
|
||||
|
||||
See [HTML with Sandwich Hime](docs/SANDWICH_HIME.md), then follow the official
|
||||
[first site tutorial](https://sandwichhime.com/docs/tutorial/) and
|
||||
[application integration tutorial](https://sandwichhime.com/docs/tutorial/application/).
|
||||
See [HTML with Sandwich Hime](docs/SANDWICH_HIME.md) and the official
|
||||
[first-site tutorial](https://sandwichhime.com/docs/tutorial/).
|
||||
|
||||
## Security boundary
|
||||
## Source, support, and licensing
|
||||
|
||||
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.
|
||||
Canonical source, issues, security policy, and release notes live on
|
||||
[Speelman Forge](https://gitea.speelman.ca/gamertan/web). GitHub is a read-only
|
||||
discovery snapshot rather than a second release origin.
|
||||
|
||||
See [SECURITY.md](SECURITY.md), [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md),
|
||||
the [application adoption contract](docs/ADOPTION.md), and
|
||||
[docs/SERVICES_ROADMAP.md](docs/SERVICES_ROADMAP.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.
|
||||
The libraries and adapters are MPL-2.0. Starters and reusable examples are
|
||||
0BSD. Future standalone services and operational machinery are
|
||||
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).
|
||||
|
||||
@@ -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
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
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 {
|
||||
User User
|
||||
Roles []string
|
||||
@@ -167,7 +173,7 @@ func (service *Service) ChangePassword(ctx context.Context, userID, currentPassw
|
||||
if !VerifyPassword(currentHash, currentPassword) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if user.Status != "active" {
|
||||
if !user.Active() {
|
||||
return ErrInactiveUser
|
||||
}
|
||||
if currentPassword == newPassword {
|
||||
@@ -199,7 +205,7 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err)
|
||||
}
|
||||
if user.Status != "active" {
|
||||
if !user.Active() {
|
||||
return User{}, ErrInactiveUser
|
||||
}
|
||||
if VerifyPassword(currentHash, input.TemporaryPassword) {
|
||||
@@ -233,24 +239,36 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP
|
||||
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) {
|
||||
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
|
||||
return "", Principal{}, errors.New("auth: invalid session lifetime")
|
||||
}
|
||||
user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier))
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
_ = VerifyPassword(dummyPasswordHash, password)
|
||||
return "", Principal{}, ErrInvalidCredentials
|
||||
}
|
||||
user, err := service.VerifyPassword(ctx, identifier, password)
|
||||
if err != nil {
|
||||
_ = VerifyPassword(dummyPasswordHash, password)
|
||||
return "", Principal{}, fmt.Errorf("auth: load credentials: %w", err)
|
||||
}
|
||||
if !VerifyPassword(hash, password) {
|
||||
return "", Principal{}, ErrInvalidCredentials
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return "", Principal{}, ErrInactiveUser
|
||||
return "", Principal{}, err
|
||||
}
|
||||
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)
|
||||
return "", Principal{}, err
|
||||
}
|
||||
if principal.User.Status != "active" {
|
||||
if !principal.User.Active() {
|
||||
_ = service.repository.DeleteSession(ctx, digest)
|
||||
return "", Principal{}, ErrInactiveUser
|
||||
}
|
||||
@@ -302,7 +320,7 @@ func (service *Service) Session(ctx context.Context, token string) (Principal, e
|
||||
if err != nil {
|
||||
return Principal{}, fmt.Errorf("auth: load session: %w", err)
|
||||
}
|
||||
if principal.User.Status != "active" {
|
||||
if !principal.User.Active() {
|
||||
_ = service.repository.DeleteSession(ctx, digest)
|
||||
return Principal{}, ErrInactiveUser
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
repositoryStub
|
||||
deleted bool
|
||||
@@ -68,6 +93,22 @@ type activeSessionRepository struct {
|
||||
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) {
|
||||
return repository.principal, Session{}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// 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/auth"
|
||||
)
|
||||
|
||||
const DefaultCodeCount = 10
|
||||
|
||||
var (
|
||||
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
|
||||
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
passwords PasswordVerifier
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
count int
|
||||
grantTTL time.Duration
|
||||
}
|
||||
|
||||
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.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute {
|
||||
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}, 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) {
|
||||
if len(raw) < 32 || len(raw) > 128 {
|
||||
return auth.User{}, ErrGrantNotFound
|
||||
}
|
||||
return service.repository.TakeRecoveryGrant(ctx, sha256.Sum256([]byte(raw)), service.now().UTC())
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authrecovery_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authrecovery"
|
||||
"gamertan.com/web/authsqlite"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+13
-5
@@ -53,9 +53,9 @@ func (store *Store) Grant(ctx context.Context, binding access.Binding) error {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
@@ -63,7 +63,7 @@ func (store *Store) Grant(ctx context.Context, binding access.Binding) error {
|
||||
if exists != 1 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -110,9 +110,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
|
||||
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
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -142,6 +143,13 @@ func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlas
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -155,7 +163,7 @@ func (store *Store) ActiveBreakGlass(ctx context.Context, organizationID, userID
|
||||
if !opaqueID(organizationID) || !opaqueID(userID) || now.IsZero() {
|
||||
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 {
|
||||
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,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
|
||||
}
|
||||
+395
-26
@@ -5,14 +5,15 @@ package authsqlite
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/organizations"
|
||||
)
|
||||
|
||||
func (store *Store) CreateOrganization(ctx context.Context, organization organizations.Organization, owner organizations.Membership) 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() {
|
||||
func (store *Store) CreateOrganization(ctx context.Context, organization organizations.Organization, owner organizations.Membership, audit organizations.AuditEvent) error {
|
||||
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")
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
@@ -24,38 +25,64 @@ func (store *Store) CreateOrganization(ctx context.Context, organization organiz
|
||||
if organization.Personal {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (store *Store) CreateTeam(ctx context.Context, team organizations.Team) error {
|
||||
if !opaqueID(team.ID) || !opaqueID(team.OrganizationID) || !slugValue(team.Slug) || !text(team.Name, 128, false) || team.CreatedAt.IsZero() {
|
||||
func (store *Store) CreateTeam(ctx context.Context, team organizations.Team, audit organizations.AuditEvent) error {
|
||||
if !validTeam(team) || !validOrganizationAudit(audit, team.OrganizationID) {
|
||||
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())
|
||||
return err
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
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 {
|
||||
if !opaqueID(membership.TeamID) || !opaqueID(membership.UserID) || membership.JoinedAt.IsZero() {
|
||||
func (store *Store) AddTeamMember(ctx context.Context, membership organizations.TeamMembership, audit organizations.AuditEvent) error {
|
||||
if !opaqueID(membership.TeamID) || !opaqueID(membership.UserID) || membership.JoinedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) {
|
||||
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
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
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 {
|
||||
@@ -96,20 +123,35 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation) 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() {
|
||||
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, 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) || !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")
|
||||
}
|
||||
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)
|
||||
SELECT ?,?,?,?,?,? FROM gwf_organization_memberships
|
||||
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)
|
||||
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 = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at,id,direct_role,team_ids_json)
|
||||
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, invitation.OrganizationID, invitation.InvitedByUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
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) {
|
||||
@@ -118,7 +160,8 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now
|
||||
}
|
||||
var invitation organizations.Invitation
|
||||
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 []byte
|
||||
err := store.db.QueryRowContext(ctx, `SELECT id,organization_id,email_normalized,invited_by_user_id,direct_role,team_ids_json,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.ID, &invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &invitation.DirectRole, &teamIDs, &created, &expires)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return organizations.Invitation{}, organizations.ErrInvitationNotFound
|
||||
}
|
||||
@@ -126,13 +169,16 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now
|
||||
return organizations.Invitation{}, err
|
||||
}
|
||||
invitation.Digest = digest
|
||||
if err = json.Unmarshal(teamIDs, &invitation.TeamIDs); err != nil || !validInvitationTeamIDs(invitation.TeamIDs) {
|
||||
return organizations.Invitation{}, organizations.ErrInvitationNotFound
|
||||
}
|
||||
invitation.CreatedAt = time.Unix(created, 0).UTC()
|
||||
invitation.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time) error {
|
||||
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() {
|
||||
func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time, audit organizations.AuditEvent) error {
|
||||
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
@@ -140,8 +186,9 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var organizationID string
|
||||
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)
|
||||
var invitationID, organizationID, directRole, invitedBy string
|
||||
var teamIDsJSON []byte
|
||||
err = tx.QueryRowContext(ctx, `SELECT i.id,i.organization_id,i.direct_role,i.team_ids_json,i.invited_by_user_id FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized JOIN gwf_organizations o ON o.id=i.organization_id AND o.status='active' WHERE i.token_hash=? AND i.used_at IS NULL AND i.revoked_at IS NULL AND i.expires_at>?`, userID, digest[:], acceptedAt.Unix()).Scan(&invitationID, &organizationID, &directRole, &teamIDsJSON, &invitedBy)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
@@ -151,6 +198,30 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
if directRole != "" {
|
||||
if !safeName(directRole) {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, "invite-"+invitationID, organizationID, userID, directRole, invitedBy, acceptedAt.Unix(), directRole)
|
||||
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[:])
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -158,6 +229,12 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
if organizationID != audit.OrganizationID {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -188,7 +265,7 @@ func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID str
|
||||
if !opaqueID(organizationID) || !opaqueID(userID) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -196,17 +273,309 @@ func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID str
|
||||
var result []organizations.Team
|
||||
for rows.Next() {
|
||||
var team organizations.Team
|
||||
var created int64
|
||||
if err = rows.Scan(&team.ID, &team.Slug, &team.Name, &created); err != nil {
|
||||
var created, updated int64
|
||||
if err = rows.Scan(&team.ID, &team.Slug, &team.Name, &team.Status, &team.Revision, &created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
team.OrganizationID = organizationID
|
||||
team.CreatedAt = time.Unix(created, 0).UTC()
|
||||
team.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
result = append(result, team)
|
||||
}
|
||||
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 {
|
||||
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()
|
||||
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 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) 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 = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); 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 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 activeOwners 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' WHERE b.organization_id=? AND b.subject_kind='user' 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, ownerRole).Scan(&activeOwners); err != nil {
|
||||
return err
|
||||
}
|
||||
if activeOwners <= 1 {
|
||||
return organizations.ErrLastOwner
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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,created_at,expires_at,COALESCE(used_at,0),COALESCE(revoked_at,0) FROM gwf_organization_invitations WHERE organization_id=? ORDER BY created_at DESC LIMIT ?`, organizationID, limit)
|
||||
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 []byte
|
||||
if err = rows.Scan(&value.ID, &value.Email, &value.InvitedByUserID, &value.DirectRole, &teamIDs, &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")
|
||||
}
|
||||
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 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 string, revokedAt time.Time, audit organizations.AuditEvent) error {
|
||||
if !opaqueID(organizationID) || !opaqueID(invitationID) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
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 {
|
||||
if len(value) < 2 || len(value) > 63 || (value[0] < 'a' || value[0] > 'z') && (value[0] < '0' || value[0] > '9') {
|
||||
return false
|
||||
|
||||
+74
-11
@@ -29,7 +29,7 @@ func (store *Store) CreatePasskeyUser(ctx context.Context, user auth.User, enrol
|
||||
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,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
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
@@ -53,14 +53,14 @@ func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (au
|
||||
if !text(identifier, 320, false) {
|
||||
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) {
|
||||
if !boundedCredentialID(credentialID) {
|
||||
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) {
|
||||
return auth.User{}, authwebauthn.ErrCredentialNotFound
|
||||
}
|
||||
@@ -93,6 +93,17 @@ func (store *Store) CredentialsByUserID(ctx context.Context, userID string) ([]a
|
||||
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 {
|
||||
if !validCredential(credential, true) || !validAuditEvent(audit) {
|
||||
return errors.New("authsqlite: invalid passkey credential")
|
||||
@@ -118,6 +129,56 @@ func (store *Store) SaveCredential(ctx context.Context, credential authwebauthn.
|
||||
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 {
|
||||
if !validCredential(credential, false) || credential.LastUsedAt.IsZero() {
|
||||
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 {
|
||||
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 {
|
||||
return auth.User{}, err
|
||||
}
|
||||
@@ -249,7 +310,7 @@ func (store *Store) RecoverUser(ctx context.Context, identifier string, enrollme
|
||||
return auth.User{}, err
|
||||
}
|
||||
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 {
|
||||
return auth.User{}, err
|
||||
}
|
||||
@@ -281,21 +342,22 @@ type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
func scanPasskeyUser(row rowScanner) (auth.User, error) {
|
||||
var user auth.User
|
||||
var passwordChangeRequired int
|
||||
var passwordChangeRequired, registrationPending int
|
||||
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) {
|
||||
return auth.User{}, auth.ErrUserNotFound
|
||||
}
|
||||
return auth.User{}, err
|
||||
}
|
||||
user.PasswordChangeRequired = passwordChangeRequired == 1
|
||||
user.RegistrationPending = registrationPending == 1
|
||||
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||
return user, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -311,9 +373,10 @@ func validCredential(credential authwebauthn.Credential, requireLabel bool) bool
|
||||
func validCeremony(ceremony authwebauthn.Ceremony) bool {
|
||||
validKind := ceremony.Kind == authwebauthn.CeremonyRegistration || ceremony.Kind == authwebauthn.CeremonyLogin || ceremony.Kind == authwebauthn.CeremonyApproval
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
return auth.AuditEvent{ID: id, Action: action, ResourceType: "user", ResourceID: resourceID, Summary: "test", CreatedAt: now}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"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) 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
|
||||
}
|
||||
+108
-12
@@ -24,6 +24,17 @@ import (
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -56,7 +67,7 @@ func Open(path string) (*Store, error) {
|
||||
store := &Store{db: db}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err = db.PingContext(ctx); err == nil {
|
||||
if err = db.PingContext(ctx); err == nil && options.Migrate {
|
||||
err = store.Migrate(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -66,6 +77,34 @@ func Open(path string) (*Store, error) {
|
||||
return store, nil
|
||||
}
|
||||
|
||||
const SchemaVersion = 8
|
||||
|
||||
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) 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()
|
||||
statements := []string{
|
||||
`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_roles (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,21 @@ 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 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 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_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 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 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_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_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 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)`,
|
||||
@@ -129,6 +173,43 @@ func (store *Store) Migrate(ctx context.Context) error {
|
||||
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 '[]'`},
|
||||
} {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -141,6 +222,18 @@ 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 {
|
||||
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
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -173,7 +266,7 @@ func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -189,9 +282,9 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
|
||||
}
|
||||
var user auth.User
|
||||
var created, updated int64
|
||||
var passwordChangeRequired int
|
||||
var passwordChangeRequired, registrationPending int
|
||||
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) {
|
||||
return auth.User{}, "", auth.ErrUserNotFound
|
||||
}
|
||||
@@ -199,6 +292,7 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
|
||||
return auth.User{}, "", err
|
||||
}
|
||||
user.PasswordChangeRequired = passwordChangeRequired == 1
|
||||
user.RegistrationPending = registrationPending == 1
|
||||
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||
return user, hash, nil
|
||||
}
|
||||
@@ -209,9 +303,9 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth
|
||||
}
|
||||
var user auth.User
|
||||
var created, updated int64
|
||||
var passwordChangeRequired int
|
||||
var passwordChangeRequired, registrationPending int
|
||||
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) {
|
||||
return auth.User{}, "", auth.ErrUserNotFound
|
||||
}
|
||||
@@ -219,6 +313,7 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth
|
||||
return auth.User{}, "", err
|
||||
}
|
||||
user.PasswordChangeRequired = passwordChangeRequired == 1
|
||||
user.RegistrationPending = registrationPending == 1
|
||||
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||
return user, hash, nil
|
||||
}
|
||||
@@ -307,12 +402,13 @@ func (store *Store) PrincipalBySession(ctx context.Context, digest [32]byte, now
|
||||
var principal auth.Principal
|
||||
var session auth.Session
|
||||
var created, updated, sessionCreated, expires, lastSeen int64
|
||||
var passwordChangeRequired 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)
|
||||
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.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) {
|
||||
return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound
|
||||
}
|
||||
principal.User.PasswordChangeRequired = passwordChangeRequired == 1
|
||||
principal.User.RegistrationPending = registrationPending == 1
|
||||
if err != nil {
|
||||
return auth.Principal{}, auth.Session{}, err
|
||||
}
|
||||
|
||||
+107
-4
@@ -17,6 +17,24 @@ import (
|
||||
"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) {
|
||||
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||
if err != nil {
|
||||
@@ -319,11 +337,11 @@ func TestOrganizationTeamResourceAndScopedAccessRoundTrip(t *testing.T) {
|
||||
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "eql", Name: "EQL"})
|
||||
@@ -366,11 +384,96 @@ func TestOrganizationTeamResourceAndScopedAccessRoundTrip(t *testing.T) {
|
||||
t.Fatalf("break-glass decision=%+v err=%v", decision, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", owner.ID, "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.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", owner.ID, "request-suspend-owner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = organizationService.RemoveMembership(t.Context(), organization.ID, member.ID, member.ID, "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)
|
||||
}
|
||||
}
|
||||
|
||||
+113
-13
@@ -190,7 +190,7 @@ func (service *Service) BeginEnrollment(ctx context.Context, enrollmentToken, la
|
||||
if err != nil {
|
||||
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) {
|
||||
@@ -198,15 +198,50 @@ func (service *Service) BeginRegistration(ctx context.Context, userID, label str
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return BeginResult{}, err
|
||||
}
|
||||
adapter, err := service.user(ctx, user)
|
||||
adapter, err := service.user(ctx, user, allowPending)
|
||||
if err != nil {
|
||||
return BeginResult{}, err
|
||||
}
|
||||
@@ -223,14 +258,62 @@ func (service *Service) beginRegistration(ctx context.Context, user auth.User, l
|
||||
if err != nil {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
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 {
|
||||
return Credential{}, errors.New("authwebauthn: registration response is invalid")
|
||||
}
|
||||
@@ -238,7 +321,7 @@ func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken st
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
adapter, err := service.user(ctx, user)
|
||||
adapter, err := service.user(ctx, user, allowPending)
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
@@ -263,11 +346,24 @@ func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken st
|
||||
}
|
||||
now := service.now().UTC()
|
||||
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 {
|
||||
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 record, nil
|
||||
@@ -310,7 +406,7 @@ func (service *Service) FinishLogin(ctx context.Context, ceremonyToken string, r
|
||||
if lookupErr != nil || account.ID != string(userHandle) {
|
||||
return nil, ErrCredentialNotFound
|
||||
}
|
||||
loaded, lookupErr = service.user(ctx, account)
|
||||
loaded, lookupErr = service.user(ctx, account, false)
|
||||
return loaded, lookupErr
|
||||
}, session, parsed)
|
||||
if err != nil || loaded == nil || user == nil {
|
||||
@@ -337,7 +433,7 @@ func (service *Service) BeginApproval(ctx context.Context, userID string, bindin
|
||||
if err != nil {
|
||||
return BeginResult{}, err
|
||||
}
|
||||
adapter, err := service.user(ctx, account)
|
||||
adapter, err := service.user(ctx, account, false)
|
||||
if err != nil {
|
||||
return BeginResult{}, err
|
||||
}
|
||||
@@ -367,7 +463,7 @@ func (service *Service) FinishApproval(ctx context.Context, ceremonyToken string
|
||||
if err != nil {
|
||||
return Approval{}, err
|
||||
}
|
||||
adapter, err := service.user(ctx, account)
|
||||
adapter, err := service.user(ctx, account, false)
|
||||
if err != nil {
|
||||
return Approval{}, err
|
||||
}
|
||||
@@ -504,8 +600,8 @@ func (service *Service) takeCeremony(ctx context.Context, token, kind string) (C
|
||||
return ceremony, nil
|
||||
}
|
||||
|
||||
func (service *Service) user(ctx context.Context, account auth.User) (*passkeyUser, error) {
|
||||
if account.Status != "active" {
|
||||
func (service *Service) user(ctx context.Context, account auth.User, allowPending bool) (*passkeyUser, error) {
|
||||
if account.Status != "active" || account.RegistrationPending && !allowPending {
|
||||
return nil, auth.ErrInactiveUser
|
||||
}
|
||||
records, err := service.repository.CredentialsByUserID(ctx, account.ID)
|
||||
@@ -594,6 +690,10 @@ func credentialRemovalBinding(userID string, credentialID []byte) []byte {
|
||||
return []byte("gamertan-web/passkey-remove/v1\x00" + userID + "\x00" + base64.RawURLEncoding.EncodeToString(credentialID))
|
||||
}
|
||||
|
||||
func passwordMigrationBinding(userID string) [32]byte {
|
||||
return BindingDigest([]byte("gamertan-web/password-to-passkey/v1\x00" + userID))
|
||||
}
|
||||
|
||||
func validateOrigin(rpID, rawOrigin string) error {
|
||||
if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" {
|
||||
return errors.New("authwebauthn: relying-party ID and origin are required")
|
||||
|
||||
@@ -4,6 +4,7 @@ package authwebauthn_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -52,6 +53,12 @@ func TestBootstrapEnrollmentAndApprovalPolicy(t *testing.T) {
|
||||
if !begin.ExpiresAt.Equal(now.Add(5 * time.Minute)) {
|
||||
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) {
|
||||
t.Fatalf("readiness without credentials err=%v", err)
|
||||
@@ -140,6 +147,51 @@ func TestRecoveryRevokesSessionsAndIssuesSingleUseEnrollment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
|
||||
if err != nil {
|
||||
|
||||
+14
-4
@@ -1,9 +1,10 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package authwebauthn provides storage-neutral, passkey-only WebAuthn
|
||||
// ceremonies. It owns relying-party policy, bounded single-use ceremony state,
|
||||
// credential lifecycle, and recovery tokens while delegating protocol parsing
|
||||
// and signature verification to a pinned WebAuthn implementation.
|
||||
// Package authwebauthn provides storage-neutral WebAuthn ceremonies for
|
||||
// passkey login, enrollment, and operation-bound step-up. It owns relying-party
|
||||
// policy, bounded single-use ceremony state, credential lifecycle, and recovery
|
||||
// tokens while delegating protocol parsing and signature verification to a
|
||||
// pinned WebAuthn implementation.
|
||||
package authwebauthn
|
||||
|
||||
import (
|
||||
@@ -25,6 +26,7 @@ var (
|
||||
ErrOperationBinding = errors.New("authwebauthn: operation binding does not match")
|
||||
ErrPasskeyReadiness = errors.New("authwebauthn: at least two passkeys are required")
|
||||
ErrUnsupportedCredential = errors.New("authwebauthn: credential algorithm is unsupported")
|
||||
ErrPasswordNotAvailable = errors.New("authwebauthn: password migration is not available")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -91,6 +93,12 @@ type Approval struct {
|
||||
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
|
||||
// enrollment tokens and ceremonies atomically and must perform recovery and
|
||||
// credential removal invariants in transactions.
|
||||
@@ -100,7 +108,9 @@ type Repository interface {
|
||||
UserByIdentifier(context.Context, string) (auth.User, error)
|
||||
UserByCredentialID(context.Context, []byte) (auth.User, error)
|
||||
CredentialsByUserID(context.Context, string) ([]Credential, error)
|
||||
PasswordCredentialExists(context.Context, string) (bool, error)
|
||||
SaveCredential(context.Context, Credential, auth.AuditEvent) error
|
||||
SaveCredentialAndRetirePassword(context.Context, Credential, auth.AuditEvent) error
|
||||
UpdateCredential(context.Context, Credential) error
|
||||
DeleteCredential(context.Context, string, []byte, int, auth.AuditEvent) 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
|
||||
@@ -0,0 +1,45 @@
|
||||
<!-- 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
|
||||
|
||||
- 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.
|
||||
+21
-1
@@ -19,13 +19,14 @@ install an imagined framework lifecycle around it.
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
The packages are ordinary Go imports. Pin the current preview and verify its
|
||||
module checksum:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.4
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.12
|
||||
go mod verify
|
||||
```
|
||||
|
||||
@@ -45,6 +46,14 @@ handler = resolver.Middleware(handler)
|
||||
The complete, copyable composition is in [`starters/basic`](../starters/basic).
|
||||
It binds to loopback, shuts down gracefully, and keeps request logging optional.
|
||||
|
||||
`requestlog.OpenJSONL` creates a private mode-`0600` file. If a separate,
|
||||
unprivileged collector such as Observatory is the only approved reader, prepare
|
||||
a trusted setgid directory whose group is that collector, then opt into
|
||||
`requestlog.OpenJSONLWithOptions(path, requestlog.JSONLOptions{FileMode: 0o640})`.
|
||||
The application still owns rotation, retention, disk monitoring, and sink-error
|
||||
health. Never use a world-readable log or add the collector to the application
|
||||
account's broader groups merely to make collection convenient.
|
||||
|
||||
Configure trusted proxy networks narrowly. A forwarding header is not evidence
|
||||
by itself; it becomes usable only when the immediate peer and skipped proxy
|
||||
hops satisfy the resolver's trust policy. Metadata, authentication, or storage
|
||||
@@ -53,6 +62,17 @@ quietly changing identity or policy.
|
||||
|
||||
## 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
|
||||
entropy. An application can store that value in a newly created private file
|
||||
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:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.4
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.12
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
Authenticated sessions currently belong to three deliberate packages:
|
||||
|
||||
+18
-9
@@ -8,10 +8,19 @@ environments; environments own application services. Teams are optional groups
|
||||
of active organization members.
|
||||
|
||||
`organizations.Service` creates those resources and issues digest-backed,
|
||||
expiring, single-use invitations. Acceptance verifies that the authenticated
|
||||
user's normalized email matches the invitation before activating membership.
|
||||
Applications own invitation pages, email or out-of-band delivery, organization
|
||||
deletion policy, and account recovery.
|
||||
expiring, single-use invitations. An invitation may carry one direct role and
|
||||
up to sixteen reviewed team memberships. Acceptance verifies that the
|
||||
authenticated user's normalized email matches and applies the membership,
|
||||
role, teams, consumption marker, and audit event in one transaction.
|
||||
Applications own invitation pages, email or out-of-band delivery, active-source
|
||||
checks before archival, and account recovery.
|
||||
|
||||
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.
|
||||
|
||||
`access.Service` evaluates a permission against a complete resource scope:
|
||||
|
||||
@@ -34,8 +43,8 @@ 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
|
||||
creates an append-only audit event in the same transaction.
|
||||
|
||||
The SQLite adapter namespaces all tables, enforces organization membership and
|
||||
resource ancestry before accepting 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.
|
||||
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.
|
||||
|
||||
+16
-3
@@ -26,17 +26,30 @@ timestamp, UUID, or counter for the random challenge.
|
||||
|
||||
## Application flow
|
||||
|
||||
1. A local command calls `Bootstrap` or `Recover` and writes the returned
|
||||
enrollment token once to a newly created mode-`0600` file.
|
||||
1. A local command calls `authwebauthn.Bootstrap`, `authwebauthn.Recover`, or
|
||||
`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
|
||||
`navigator.credentials.create` with the returned `public_key` value.
|
||||
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`.
|
||||
The successful result contains an ordinary opaque `auth` session token.
|
||||
5. Sensitive operations call `BeginApproval` with a canonical application
|
||||
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
|
||||
JSON framing only. They do not register routes, authorize requests, serve
|
||||
JavaScript, or set sessions automatically.
|
||||
|
||||
@@ -66,8 +66,10 @@ accepted from request input. Break-glass access lasts at most one hour and is
|
||||
not a substitute for ordinary role policy.
|
||||
|
||||
Local storage adapters assume the parent directory and host account are trusted.
|
||||
They reject a symlink at the configured final path and apply private file modes,
|
||||
They reject a symlink at the configured final path and apply bounded file modes,
|
||||
but they do not defend against a concurrent privileged actor replacing path
|
||||
ancestors during an open. The synchronous JSONL adapter deliberately favors
|
||||
durable, bounded evidence over maximum request throughput; the application owns
|
||||
rotation, retention, disk monitoring, and health escalation.
|
||||
rotation, retention, disk monitoring, and health escalation. Its default is
|
||||
mode `0600`; the sole wider option is mode `0640` for a deployment-assigned
|
||||
collector group. The toolkit does not select or change that group.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
// 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"
|
||||
"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" && bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||
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
|
||||
}
|
||||
|
||||
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,52 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"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) {
|
||||
prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prepared.Kind != KindAttachment || prepared.MediaType != "application/pdf" {
|
||||
t.Fatalf("prepared=%+v", prepared)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,65 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package medialocal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"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)
|
||||
}
|
||||
prepared, err := media.Prepare(bytes.NewReader([]byte("%PDF-1.7\nfixture")), "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")
|
||||
}
|
||||
}
|
||||
+295
-37
@@ -19,18 +19,25 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvitationNotFound = errors.New("organizations: invitation not found")
|
||||
ErrMembershipNotFound = errors.New("organizations: membership not found")
|
||||
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||
ErrInvitationNotFound = errors.New("organizations: invitation not found")
|
||||
ErrMembershipNotFound = errors.New("organizations: membership not found")
|
||||
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")
|
||||
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||
)
|
||||
|
||||
type Organization struct {
|
||||
ID string
|
||||
Slug string
|
||||
Name string
|
||||
Personal bool
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
Slug string
|
||||
Name string
|
||||
Status string
|
||||
Personal bool
|
||||
Revision int64
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
@@ -41,8 +48,9 @@ type Membership struct {
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
ID, OrganizationID, Slug, Name string
|
||||
CreatedAt time.Time
|
||||
ID, OrganizationID, Slug, Name, Status string
|
||||
Revision int64
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type TeamMembership struct {
|
||||
@@ -66,35 +74,54 @@ type ApplicationService struct {
|
||||
}
|
||||
|
||||
type Invitation struct {
|
||||
Digest [32]byte
|
||||
OrganizationID string
|
||||
Email, InvitedByUserID string
|
||||
CreatedAt, ExpiresAt, UsedAt time.Time
|
||||
ID string
|
||||
Digest [32]byte
|
||||
OrganizationID string
|
||||
Email, InvitedByUserID string
|
||||
DirectRole 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 {
|
||||
CreateOrganization(context.Context, Organization, Membership) error
|
||||
CreateTeam(context.Context, Team) error
|
||||
AddTeamMember(context.Context, TeamMembership) error
|
||||
CreateOrganization(context.Context, Organization, Membership, AuditEvent) error
|
||||
OrganizationByID(context.Context, string) (Organization, 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
|
||||
CreateEnvironment(context.Context, Environment) error
|
||||
CreateApplicationService(context.Context, ApplicationService) error
|
||||
CreateInvitation(context.Context, Invitation) error
|
||||
CreateInvitation(context.Context, Invitation, AuditEvent) 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, time.Time, AuditEvent) error
|
||||
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
|
||||
MembershipsForUser(context.Context, string) ([]Membership, error)
|
||||
TeamsForUser(context.Context, string, string) ([]Team, error)
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
OwnerRole string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
ownerRole string
|
||||
}
|
||||
|
||||
func New(repository Repository, options Options) (*Service, error) {
|
||||
@@ -107,7 +134,10 @@ func New(repository Repository, options Options) (*Service, error) {
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
return &Service{repository: repository, random: options.Random, now: options.Now}, nil
|
||||
if options.OwnerRole != "" && !safeNamePattern.MatchString(options.OwnerRole) {
|
||||
return nil, errors.New("organizations: owner role is invalid")
|
||||
}
|
||||
return &Service{repository: repository, random: options.Random, now: options.Now, ownerRole: options.OwnerRole}, nil
|
||||
}
|
||||
|
||||
type CreateOrganization struct {
|
||||
@@ -126,9 +156,13 @@ func (service *Service) CreateOrganization(ctx context.Context, input CreateOrga
|
||||
return Organization{}, err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
organization := Organization{ID: id, Slug: input.Slug, Name: input.Name, Personal: input.Personal, CreatedAt: now}
|
||||
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}
|
||||
if err = service.repository.CreateOrganization(ctx, organization, owner); err != nil {
|
||||
audit, err := service.audit(input.OwnerUserID, id, "organization.create", "organization", id, "Organization created")
|
||||
if err != nil {
|
||||
return Organization{}, err
|
||||
}
|
||||
if err = service.repository.CreateOrganization(ctx, organization, owner, audit); err != nil {
|
||||
return Organization{}, err
|
||||
}
|
||||
return organization, nil
|
||||
@@ -143,30 +177,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})
|
||||
}
|
||||
|
||||
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) {
|
||||
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
|
||||
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")
|
||||
}
|
||||
id, err := token(service.random, 18)
|
||||
if err != nil {
|
||||
return Team{}, err
|
||||
}
|
||||
team := Team{ID: id, OrganizationID: input.OrganizationID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
|
||||
if err = service.repository.CreateTeam(ctx, team); err != nil {
|
||||
now := service.now().UTC()
|
||||
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, nil
|
||||
}
|
||||
|
||||
func (service *Service) AddTeamMember(ctx context.Context, teamID, userID string) error {
|
||||
if !idPattern.MatchString(teamID) || !idPattern.MatchString(userID) {
|
||||
func (service *Service) AddTeamMember(ctx context.Context, teamID, userID, actorUserID string) error {
|
||||
if !idPattern.MatchString(teamID) || !idPattern.MatchString(userID) || !idPattern.MatchString(actorUserID) {
|
||||
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 }
|
||||
@@ -224,17 +271,37 @@ 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) {
|
||||
return service.InviteWithAccess(ctx, InviteWithAccess{OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, Lifetime: lifetime})
|
||||
}
|
||||
|
||||
type InviteWithAccess struct {
|
||||
OrganizationID, Email, InvitedByUserID, DirectRole 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))
|
||||
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) {
|
||||
return "", Invitation{}, errors.New("organizations: invalid invitation")
|
||||
}
|
||||
id, err := token(service.random, 18)
|
||||
if err != nil {
|
||||
return "", Invitation{}, err
|
||||
}
|
||||
raw, err := token(service.random, 32)
|
||||
if err != nil {
|
||||
return "", Invitation{}, err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
invitation := Invitation{Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, CreatedAt: now, ExpiresAt: now.Add(lifetime)}
|
||||
if err = service.repository.CreateInvitation(ctx, invitation); err != nil {
|
||||
invitation := Invitation{ID: id, Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, DirectRole: input.DirectRole, TeamIDs: append([]string(nil), input.TeamIDs...), CreatedAt: now, ExpiresAt: now.Add(lifetime)}
|
||||
audit, err := service.audit(invitedBy, organizationID, "invitation.create", "invitation", id, "Organization invitation created")
|
||||
if err != nil {
|
||||
return "", Invitation{}, err
|
||||
}
|
||||
if err = service.repository.CreateInvitation(ctx, invitation, audit); err != nil {
|
||||
return "", Invitation{}, err
|
||||
}
|
||||
return raw, invitation, nil
|
||||
@@ -246,10 +313,15 @@ func (service *Service) AcceptInvitation(ctx context.Context, rawToken, userID s
|
||||
}
|
||||
digest := sha256.Sum256([]byte(rawToken))
|
||||
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 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
|
||||
}
|
||||
return service.repository.AcceptInvitation(ctx, digest, userID, now, audit)
|
||||
}
|
||||
|
||||
func (service *Service) Memberships(ctx context.Context, userID string) ([]Membership, error) {
|
||||
@@ -266,8 +338,171 @@ func (service *Service) Teams(ctx context.Context, organizationID, userID string
|
||||
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) {
|
||||
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
|
||||
}
|
||||
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 err = service.repository.UpdateOrganization(ctx, value, input.ExpectedRevision, audit); 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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, now, audit)
|
||||
}
|
||||
|
||||
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) {
|
||||
value := make([]byte, size)
|
||||
if _, err := io.ReadFull(random, value); err != nil {
|
||||
@@ -279,3 +514,26 @@ func token(random io.Reader, size int) (string, error) {
|
||||
func bounded(value string, limit int) bool {
|
||||
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}$`)
|
||||
|
||||
@@ -54,18 +54,39 @@ type repositoryStub struct {
|
||||
acceptedUser string
|
||||
}
|
||||
|
||||
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership) error {
|
||||
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership, _ AuditEvent) error {
|
||||
repository.organization = organization
|
||||
return nil
|
||||
}
|
||||
func (*repositoryStub) CreateTeam(context.Context, Team) error { return nil }
|
||||
func (*repositoryStub) AddTeamMember(context.Context, TeamMembership) error { return nil }
|
||||
func (repository *repositoryStub) OrganizationByID(context.Context, string) (Organization, error) {
|
||||
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) CreateEnvironment(context.Context, Environment) error { return nil }
|
||||
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
|
||||
return nil
|
||||
}
|
||||
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation) error {
|
||||
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ AuditEvent) error {
|
||||
repository.invitation = invitation
|
||||
return nil
|
||||
}
|
||||
@@ -75,7 +96,13 @@ func (repository *repositoryStub) InvitationByDigest(context.Context, [32]byte,
|
||||
}
|
||||
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, time.Time, AuditEvent) error {
|
||||
return nil
|
||||
}
|
||||
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time, _ AuditEvent) error {
|
||||
repository.acceptedUser = userID
|
||||
return nil
|
||||
}
|
||||
|
||||
+22
-2
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -20,20 +21,39 @@ type JSONL struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// JSONLOptions controls the local file boundary. A zero FileMode preserves the
|
||||
// private 0600 default. Mode 0640 may be used when the deployment has assigned
|
||||
// the file to one explicit collector group; world-readable or writable modes
|
||||
// are never accepted.
|
||||
type JSONLOptions struct {
|
||||
FileMode fs.FileMode
|
||||
}
|
||||
|
||||
func OpenJSONL(path string) (*JSONL, error) {
|
||||
return OpenJSONLWithOptions(path, JSONLOptions{})
|
||||
}
|
||||
|
||||
func OpenJSONLWithOptions(path string, options JSONLOptions) (*JSONL, error) {
|
||||
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
|
||||
return nil, errors.New("requestlog: JSONL path must be clean and absolute")
|
||||
}
|
||||
mode := options.FileMode
|
||||
if mode == 0 {
|
||||
mode = 0o600
|
||||
}
|
||||
if mode != 0o600 && mode != 0o640 {
|
||||
return nil, errors.New("requestlog: JSONL mode must be 0600 or 0640")
|
||||
}
|
||||
if info, err := os.Lstat(path); err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) {
|
||||
return nil, errors.New("requestlog: JSONL destination must be a regular file")
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = file.Chmod(0o600); err != nil {
|
||||
if err = file.Chmod(mode); err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
package requestlog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -208,3 +210,17 @@ func (capture *responseCapture) Write(body []byte) (int, error) {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -24,6 +26,16 @@ type memorySink struct {
|
||||
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 {
|
||||
sink.records = append(sink.records, record)
|
||||
sink.ctxErr = ctx.Err()
|
||||
@@ -101,6 +113,33 @@ func TestJSONLRoundTripAndMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLAllowsExplicitCollectorGroupRead(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "access.jsonl")
|
||||
sink, err := OpenJSONLWithOptions(path, JSONLOptions{FileMode: 0o640})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = sink.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 {
|
||||
t.Fatalf("mode=%o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLRejectsOverlyPermissiveMode(t *testing.T) {
|
||||
for _, mode := range []os.FileMode{0o400, 0o620, 0o644, 0o660, 0o666} {
|
||||
path := filepath.Join(t.TempDir(), "access.jsonl")
|
||||
if _, err := OpenJSONLWithOptions(path, JSONLOptions{FileMode: mode}); err == nil {
|
||||
t.Fatalf("accepted mode %o", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanicIsRecordedAndRepanicked(t *testing.T) {
|
||||
sink := &memorySink{}
|
||||
handler := Middleware(sink, Policy{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("expected") }))
|
||||
@@ -181,3 +220,29 @@ func TestResponseStatusUsesFirstHeader(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,22 +17,34 @@ packages=(
|
||||
webauthn
|
||||
)
|
||||
|
||||
install -D -m 0644 "$source_root/LICENSE" "$derived/LICENSE"
|
||||
install_file() {
|
||||
source=$1
|
||||
destination=$2
|
||||
mkdir -p "$(dirname "$destination")"
|
||||
install -m 0644 "$source" "$destination"
|
||||
}
|
||||
|
||||
install_file "$source_root/LICENSE" "$derived/LICENSE"
|
||||
for package in "${packages[@]}"; do
|
||||
while IFS= read -r source; do
|
||||
for source in "$source_root/$package"/*.go; do
|
||||
case $source in
|
||||
*_test.go) continue ;;
|
||||
esac
|
||||
relative=${source#"$source_root"/}
|
||||
install -D -m 0644 "$source" "$derived/$relative"
|
||||
done < <(find "$source_root/$package" -maxdepth 1 -type f -name '*.go' ! -name '*_test.go' | sort)
|
||||
install_file "$source" "$derived/$relative"
|
||||
done
|
||||
done
|
||||
|
||||
while IFS= read -r source; do
|
||||
sed -i \
|
||||
temporary="$source.tmp"
|
||||
sed \
|
||||
's#github.com/go-webauthn/webauthn#gamertan.com/web/internal/webauthnvendored#g' \
|
||||
"$source"
|
||||
"$source" >"$temporary"
|
||||
mv "$temporary" "$source"
|
||||
done < <(find "$derived" -type f -name '*.go' | sort)
|
||||
|
||||
cmp -s LICENSES/BSD-3-Clause-go-webauthn.txt "$embedded_root/LICENSE"
|
||||
if ! diff -ru --no-dereference "$derived" "$embedded_root"; then
|
||||
if ! diff -ru "$derived" "$embedded_root"; then
|
||||
echo 'compiled WebAuthn verifier differs from its audited mechanical derivation' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -6,7 +6,11 @@ cd "$root"
|
||||
|
||||
test -f third_party/go-webauthn/LICENSE
|
||||
cmp -s LICENSES/BSD-3-Clause-go-webauthn.txt third_party/go-webauthn/LICENSE
|
||||
sha256sum -c third_party/go-webauthn.SHA256SUMS >/dev/null
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum -c third_party/go-webauthn.SHA256SUMS >/dev/null
|
||||
else
|
||||
shasum -a 256 -c third_party/go-webauthn.SHA256SUMS >/dev/null
|
||||
fi
|
||||
expected=$(sed -n 's# third_party/go-webauthn/.*#&#p' third_party/go-webauthn.SHA256SUMS | wc -l)
|
||||
actual=$(find third_party/go-webauthn -type f | wc -l)
|
||||
test "$expected" -eq "$actual"
|
||||
|
||||
@@ -8,7 +8,10 @@ output=$1
|
||||
[[ $output = /* && $output != / && ! -e $output ]] || usage
|
||||
cd "$root"
|
||||
[[ -z $(git status --porcelain=v1 --untracked-files=all) ]] || { echo "private source must be clean" >&2; exit 1; }
|
||||
mapfile -t files < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow)
|
||||
files=()
|
||||
while IFS= read -r file; do
|
||||
files+=("$file")
|
||||
done < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow)
|
||||
[[ ${#files[@]} -gt 0 ]] || exit 1
|
||||
for file in "${files[@]}"; do
|
||||
[[ $file != /* && $file != *..* ]] || { echo "invalid allowlisted path: $file" >&2; exit 1; }
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
.gitignore
|
||||
CHANGELOG.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
LICENSES.md
|
||||
LICENSES/0BSD.txt
|
||||
LICENSES/AGPL-3.0-only.txt
|
||||
@@ -16,6 +17,7 @@ SECURITY.md
|
||||
THIRD_PARTY_NOTICES.md
|
||||
abuse/abuse.go
|
||||
abuse/abuse_test.go
|
||||
account/account.go
|
||||
access/access.go
|
||||
access/access_test.go
|
||||
analytics/analytics.go
|
||||
@@ -26,6 +28,8 @@ auth/auth.go
|
||||
auth/context.go
|
||||
auth/password.go
|
||||
auth/password_test.go
|
||||
authrecovery/recovery.go
|
||||
authrecovery/recovery_test.go
|
||||
auth/service_test.go
|
||||
authhttp/authhttp.go
|
||||
authhttp/authhttp_test.go
|
||||
@@ -33,18 +37,30 @@ authhttp/passkey.go
|
||||
authhttp/passkey_test.go
|
||||
authsqlite/store.go
|
||||
authsqlite/store_test.go
|
||||
authsqlite/account.go
|
||||
authsqlite/account_test.go
|
||||
authsqlite/access.go
|
||||
authsqlite/bootstrap.go
|
||||
authsqlite/bootstrap_test.go
|
||||
authsqlite/organizations.go
|
||||
authsqlite/passkey.go
|
||||
authsqlite/passkey_test.go
|
||||
authsqlite/recovery.go
|
||||
authwebauthn/fuzz_test.go
|
||||
authwebauthn/service.go
|
||||
authwebauthn/service_test.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/
|
||||
docs/ADOPTION.md
|
||||
docs/ARCHITECTURE.md
|
||||
docs/DEPENDENCIES.md
|
||||
docs/DOGFOOD.md
|
||||
docs/GETTING_STARTED.md
|
||||
docs/MODULES.md
|
||||
docs/ORGANIZATIONS.md
|
||||
@@ -53,6 +69,8 @@ docs/PUBLIC_SNAPSHOT.md
|
||||
docs/SANDWICH_HIME.md
|
||||
docs/SERVICES_ROADMAP.md
|
||||
docs/THREAT_MODEL.md
|
||||
doc.go
|
||||
example_test.go
|
||||
go.mod
|
||||
go.sum
|
||||
requestlog/jsonl.go
|
||||
|
||||
@@ -6,14 +6,14 @@ cd "$root"
|
||||
temporary=$(mktemp -d)
|
||||
trap 'rm -rf "$temporary"' EXIT
|
||||
./scripts/export-public.sh "$temporary/export"
|
||||
(cd "$temporary/export" && find . -type f -printf '%P\n' | sort) >"$temporary/actual"
|
||||
(cd "$temporary/export" && find . -type f -print | sed 's#^\./##' | LC_ALL=C sort) >"$temporary/actual"
|
||||
while IFS= read -r path; do
|
||||
if [[ $path = */ ]]; then
|
||||
find "${path%/}" -type f -printf '%p\n'
|
||||
find "${path%/}" -type f -print
|
||||
else
|
||||
echo "$path"
|
||||
fi
|
||||
done < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) | sort >"$temporary/expected"
|
||||
done < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) | LC_ALL=C sort >"$temporary/expected"
|
||||
diff -u "$temporary/expected" "$temporary/actual"
|
||||
private_word='PRI''VATE'
|
||||
token_word='to''ken'
|
||||
|
||||
@@ -5,6 +5,7 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$root"
|
||||
./scripts/check-licenses.sh
|
||||
./scripts/check-dependencies.sh
|
||||
./scripts/test-public-snapshot.sh
|
||||
test -z "$(find . \( -path ./third_party -o -path ./internal/webauthnvendored \) -prune -o -name '*.go' -print0 | xargs -0 gofmt -l)"
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
|
||||
Reference in New Issue
Block a user