diff --git a/.coverage-exclusions.json b/.coverage-exclusions.json index 54e2b9ae..bc95a524 100644 --- a/.coverage-exclusions.json +++ b/.coverage-exclusions.json @@ -483,12 +483,12 @@ { "package": "internal/service", "file": "internal/service/mint.go", - "line": 393, + "line": 419, "occurrence": 1, "source": "\t\treturn nil, ErrMintRoleNotPermitted", "bucket": "C", "confirmed": true, - "justification": "checkRoles' second, defence-in-depth role filter. Reaching it needs a role that the loop above accepted, meaning !seed.IsReservedAdminRole(r), and that seed.FilterUserRoles then dropped. FilterUserRoles (internal/seed/seed.go:148) drops exactly the roles for which IsReservedAdminRole(r) is true, so the two predicates are the same case-folded lookup on the same slice and the lengths cannot differ. The early return at internal/service/mint.go:382 means requested is never empty, which is FilterUserRoles' only other way to shorten the result." + "justification": "checkRoles' second, defence-in-depth role filter. Reaching it needs a role that the loop above accepted, meaning !seed.IsReservedAdminRole(r), and that seed.FilterUserRoles then dropped. FilterUserRoles (internal/seed/seed.go:148) drops exactly the roles for which IsReservedAdminRole(r) is true, so the two predicates are the same case-folded lookup on the same slice and the lengths cannot differ. The early return at internal/service/mint.go:407 means requested is never empty, which is FilterUserRoles' only other way to shorten the result." }, { "package": "internal/service", diff --git a/README.md b/README.md index a1aa4183..e7692f81 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Vault42 issues its own tokens and is an OAuth2 *client* of other providers. It i | ![Go](https://img.shields.io/badge/Go-1.26.6-00ADD8?style=flat&logo=go&logoColor=white) | ![Vue](https://img.shields.io/badge/Vue-3.5.41-4FC08D?style=flat&logo=vuedotjs&logoColor=white) | ![.NET](https://img.shields.io/badge/.NET-10.0-512BD4?style=flat&logo=dotnet&logoColor=white) | ![License](https://img.shields.io/badge/License-MIT-155724?style=flat&labelColor=000) | | ![Go Tests](https://img.shields.io/badge/Tests-4910-155724?style=flat&labelColor=000) | ![Vue Tests](https://img.shields.io/badge/Tests-1305-155724?style=flat&labelColor=000) | ![C# Tests](https://img.shields.io/badge/Tests-264-155724?style=flat&labelColor=000) | ![Total](https://img.shields.io/badge/Total-6479_tests-155724?style=flat&labelColor=000) | | ![Go Coverage](https://img.shields.io/badge/Coverage-100.00%25_reachable-155724?style=flat&labelColor=000) | ![Vue Coverage](https://img.shields.io/badge/Coverage-99.76%25-155724?style=flat&labelColor=000) | ![C# Coverage](https://img.shields.io/badge/Coverage-100.00%25-155724?style=flat&labelColor=000) | ![Locales](https://img.shields.io/badge/Locales-38-555?style=flat&labelColor=000) | -| ![Go Lines](https://img.shields.io/badge/Lines-48229-555?style=flat&labelColor=000) | ![Vue Lines](https://img.shields.io/badge/Lines-6800-555?style=flat&labelColor=000) | ![C# Lines](https://img.shields.io/badge/Lines-2435-555?style=flat&labelColor=000) | ![Standards](https://img.shields.io/badge/Standards-11-555?style=flat&labelColor=000) | +| ![Go Lines](https://img.shields.io/badge/Lines-48360-555?style=flat&labelColor=000) | ![Vue Lines](https://img.shields.io/badge/Lines-6801-555?style=flat&labelColor=000) | ![C# Lines](https://img.shields.io/badge/Lines-2435-555?style=flat&labelColor=000) | ![Standards](https://img.shields.io/badge/Standards-11-555?style=flat&labelColor=000) | | ![Go Deps](https://img.shields.io/badge/Deps-3-555?style=flat&labelColor=000) | ![Vue Deps](https://img.shields.io/badge/Deps-3-555?style=flat&labelColor=000) | ![C# Deps](https://img.shields.io/badge/Deps-6-555?style=flat&labelColor=000) | ![Requirements](https://img.shields.io/badge/Requirements-456-555?style=flat&labelColor=000) | | ![Go Transitive Deps](https://img.shields.io/badge/Transitive-15-555?style=flat&labelColor=000) | ![Vue Transitive Deps](https://img.shields.io/badge/Transitive-95-555?style=flat&labelColor=000) | ![C# Transitive Deps](https://img.shields.io/badge/Transitive-26-555?style=flat&labelColor=000) | ![Total Deps](https://img.shields.io/badge/Deps-148_total-555?style=flat&labelColor=000) | diff --git a/charts/vault/templates/configmap.yaml b/charts/vault/templates/configmap.yaml index 0e6922fd..5556bae3 100644 --- a/charts/vault/templates/configmap.yaml +++ b/charts/vault/templates/configmap.yaml @@ -81,6 +81,7 @@ data: VAULT_MINT_MAX_TTL: {{ .Values.mint.maxTTL | quote }} VAULT_MINT_ROLES: {{ join "," .Values.mint.allowedRoles | quote }} VAULT_MINT_SCOPES: {{ join "," .Values.mint.allowedScopes | quote }} + VAULT_MINT_ALLOW_EMAIL: {{ .Values.mint.allowEmail | quote }} VAULT_EMAIL_FROM: {{ .Values.emailFrom | quote }} {{- if and (eq .Values.email.provider "sendgrid") (not .Values.secrets.keys.sendgridApiKey) }} {{- fail "email.provider is sendgrid and secrets.keys.sendgridApiKey is empty. cmd/vault selects the SendGrid sender only when the provider name AND the API key are both present; with the key missing it falls through to SMTP, or to no sender at all when smtp.host is empty, and every verification, reset and one-time-code mail is silently dropped by a deployment that reports healthy. Name the key in the release Secret, or leave the provider on smtp." }} diff --git a/charts/vault/values.yaml b/charts/vault/values.yaml index 18b47fd8..e88329e7 100644 --- a/charts/vault/values.yaml +++ b/charts/vault/values.yaml @@ -337,7 +337,7 @@ outboundAllowPrivate: false # decision about the caller, not a feature toggle -- see docs/security.md. # # Every value below is the binary's own default, so a release that does not set -# mint.enabled is unchanged by this block existing. The four settings beside the +# mint.enabled is unchanged by this block existing. The six settings beside the # switch are rendered with it because none of them works alone: without an # audience the process refuses to start, and with empty allow-lists the endpoint # mounts and grants nothing. @@ -366,6 +366,13 @@ mint: # comma-joined. Empty means no scope may be minted. Capability scopes such as # kms:unwrap and mint:token are refused regardless of what is listed here. allowedScopes: [] + # -- Permit an email claim on a minted token (VAULT_MINT_ALLOW_EMAIL). Off by + # default. Every other minted claim is a name vault42 issued or allow-listed; + # an email is a caller-supplied identifier for a subject vault42 has never + # heard of, so turning this on says the operator trusts this client to assert + # who its users are. A request carrying an email while this is off is refused + # rather than stripped, so the misconfiguration is visible. + allowEmail: false # -- Hosts an issuer's discovery document may name beyond the issuer's own # domain (VAULT_OUTBOUND_ALLOWED_HOSTS), rendered comma-joined. Empty is the diff --git a/cmd/vault/main.go b/cmd/vault/main.go index 359c7cc9..51136017 100644 --- a/cmd/vault/main.go +++ b/cmd/vault/main.go @@ -576,6 +576,7 @@ func main() { MaxTTL: cfg.MintMaxTTL, AllowedRoles: cfg.MintAllowedRoles, AllowedScopes: cfg.MintAllowedScopes, + AllowEmail: cfg.MintAllowEmail, }, mintMetrics) if err != nil { log.Fatalf("Failed to initialize mint service: %v", err) diff --git a/docs/api.md b/docs/api.md index bafb3483..8652adba 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3165,6 +3165,7 @@ Mounted **only** when `VAULT_MINT_ENABLED=true`; otherwise the route does not ex | `roles` | string[] | No | Every member must appear in `VAULT_MINT_ROLES` | Omit or send `[]` for no roles. The allow-list is empty by default, so a freshly enabled mint issues bare subject assertions | | `scopes` | string[] | No | Every member must appear in `VAULT_MINT_SCOPES` | Same deny-by-default rule as `roles` | | `ttl_seconds` | int | No | `0` or absent means `VAULT_MINT_TOKEN_TTL`; otherwise `0 < ttl <= VAULT_MINT_MAX_TTL`, itself capped at 900 in code | A value above the ceiling is **refused, not clamped**. Silently issuing something other than what was asked for hides a misconfigured caller until the day its tokens expire mid-flight | +| `email` | string | No | Requires `VAULT_MINT_ALLOW_EMAIL`; must pass the same validator as a registered address, at most 254 bytes | Lower-cased and trimmed before signing. Refused with `403 email_not_permitted` when the setting is off, rather than stripped. vault42 does not verify it and cannot: `/mint` asserts subjects it has never heard of, and the email is a claim about the same unknown subject | Unknown keys are rejected (`DisallowUnknownFields`), so a typo in a field name fails the whole request with `400 invalid_request`. @@ -3180,6 +3181,7 @@ Unknown keys are rejected (`DisallowUnknownFields`), so a typo in a field name f "issuer": "https://vault42.example.com", "roles": ["rider"], "scopes": ["orders:read"], + "email": "legacy-user-8814@example.com", "kid": "4f1c9e60-2a77-4e0f-9a3e-9c2b7f0d51aa", "jti": "0f2b8c1d-6e4a-4c92-b8a1-2f7d3e5a90c4" } @@ -3195,6 +3197,7 @@ Unknown keys are rejected (`DisallowUnknownFields`), so a typo in a field name f | `issuer` | string | `VAULT_ORIGIN`, the `iss` claim on the token | | `roles` | string[] | Granted roles. Omitted when none were requested | | `scopes` | string[] | Granted scopes. Omitted when none were requested | +| `email` | string | Echo of the asserted address as it was signed, lower-cased and trimmed. Omitted when the request carried none. Present so a caller can see what it actually asserted rather than what it meant to send | | `kid` | string | Key id the assertion was signed under, resolvable against `GET /.well-known/jwks.json` | | `jti` | string | The token's unique id, also recorded in the audit event so a downstream incident traces back to the exact assertion | @@ -3212,6 +3215,7 @@ Unknown keys are rejected (`DisallowUnknownFields`), so a typo in a field name f | `token_type` | `mint` | | `minted_by` | The `client_id` of the client that requested the mint. This is the attribution a relying party can act on: the `token_minted` audit event names the same client, but that row lives in vault42's database and an RP cannot read it | | `client_id` | **Absent, deliberately.** A minted token must not look like an authenticated service caller. The service document store treats the presence of this claim as proof of one and uses it as the ownership axis, so a minted token carrying it would be admitted as the minting client. That is why the attribution claim is spelled `minted_by`. See the security notes below | +| `email` | The address the caller asserted, present only when `VAULT_MINT_ALLOW_EMAIL` is on and the request carried one. **Not verified.** vault42 never looked this subject up, so the claim is the caller's statement rather than vault42's. A relying party must not treat it as proof of address ownership, and no vault42-issued login token carries this claim at all | | `fingerprint`, `cnf` | Absent. A minted token is not device-bound and not sender-constrained | There is no refresh token and no stored session behind a minted token. It cannot be exchanged, rotated, extended or revoked; vault42 keeps no record of it beyond the audit event. @@ -3233,6 +3237,8 @@ There is no refresh token and no stored session behind a minted token. It cannot | 403 | `insufficient_scope` | Token lacks the `mint:token` scope | | 403 | `client_credentials_required` | Token has the scope but no `client_id` claim, so it is not a service client | | 403 | `role_not_permitted` | A requested role is outside `VAULT_MINT_ROLES`, or is `admin` or `super_admin` in any casing | +| 403 | `email_not_permitted` | The request carried `email` and `VAULT_MINT_ALLOW_EMAIL` is off | +| 400 | `invalid_email` | The request carried an `email` the address validator rejected | | 403 | `scope_not_permitted` | A requested scope is outside `VAULT_MINT_SCOPES`, or is one of the vault42 capability scopes | | 429 | `rate_limit_exceeded` | Rate limit exceeded | | 500 | `internal_error` | Signing or UUID generation failed | diff --git a/docs/badges.json b/docs/badges.json index bc7388d7..8bc71409 100644 --- a/docs/badges.json +++ b/docs/badges.json @@ -9,8 +9,8 @@ "reachableCoverageNum": 100.00, "packages": 43, "goFiles": 192, - "goLines": 48229, - "testFiles": 928, + "goLines": 48360, + "testFiles": 929, "directDeps": 3, "transitiveDeps": 15, "totalTests": 6479, @@ -20,7 +20,7 @@ "tests": 4910, "coverage": "100.00% of reachable", "coverageNum": 100.00, - "lines": 48229, + "lines": 48360, "deps": 3, "transitiveDeps": 15 }, @@ -28,7 +28,7 @@ "tests": 1305, "coverage": "99.76%", "coverageNum": 99.76, - "lines": 6800, + "lines": 6801, "deps": 3, "transitiveDeps": 95 }, diff --git a/docs/compliance-register.json b/docs/compliance-register.json index 97e22fd9..08506a92 100644 --- a/docs/compliance-register.json +++ b/docs/compliance-register.json @@ -191,7 +191,7 @@ "accepted_on": "2026-08-18", "security_md": "Register-scoped. docs/security.md owns the AR-nn namespace and defines a different risk under this number; the two lists are deliberately disjoint.", "rationale": "ASVS V3.5.4 wants separate applications on separate hostnames so the same-origin policy separates them. When VAULT_SERVE_FRONTEND is on, internal/server/server.go:847 mounts the embedded SPA at the catch-all route of the same server that answers /auth, /user and /client, so the two share an origin by construction and the policy separates nothing.", - "compensating_control": "VAULT_SERVE_FRONTEND is off by default (internal/config/config.go:505) and the chart ships serveFrontend: false, so the single-origin topology is opt-in; the honeypot profile is the one place that forces it on. The Content-Security-Policy applied when the frontend is served names script-src 'self' with no unsafe-inline, so even in the shared-origin shape a script the SPA did not author does not execute.\n\nThe chart offers the separated topology rather than shipping it, and the earlier wording did not make that distinction: charts/vault/templates/frontend.yaml deploys the SPA as its own nginx workload, but frontend.enabled is false by default and the template carries no hostname of its own. The separate hostname comes from ingress.hosts, which the operator supplies. So the preferred topology is available and configured, not default.", + "compensating_control": "VAULT_SERVE_FRONTEND is off by default (internal/config/config.go:518) and the chart ships serveFrontend: false, so the single-origin topology is opt-in; the honeypot profile is the one place that forces it on. The Content-Security-Policy applied when the frontend is served names script-src 'self' with no unsafe-inline, so even in the shared-origin shape a script the SPA did not author does not execute.\n\nThe chart offers the separated topology rather than shipping it, and the earlier wording did not make that distinction: charts/vault/templates/frontend.yaml deploys the SPA as its own nginx workload, but frontend.enabled is false by default and the template carries no hostname of its own. The separate hostname comes from ingress.hosts, which the operator supplies. So the preferred topology is available and configured, not default.", "residual_risk": "An operator who enables the embedded frontend gives up the origin boundary between the SPA and the API, so an XSS in the SPA runs on the API's origin.", "revisit_when": "The embedded frontend becomes the default, or the SPA gains a surface that renders content it did not author.", "cost_of_closing": "The earlier estimate said nothing to build, and that was optimistic in a way worth naming: no amount of documentation makes the requirement true, because the topology is the operator's to choose and vault42 can only offer one and say which it supports. docs/deployment-guide.md now carries that section -- the two shapes, the two settings that are both off by default and both needed, and what the single-origin shape is relying on when it is used anyway. The row stays accepted because a deployment can still be single-origin, and a register that marked it Met would be describing a default rather than a guarantee." @@ -613,7 +613,7 @@ "tests": [ "TestASVS_V1_3_6_DiscoverySuppliedDestinationsAreCheckedAgainstAnAllowlist" ], - "notes": "The untrusted data this requirement names is the issuer's discovery document, and the four endpoints it supplies -- authorization_endpoint, token_endpoint, userinfo_endpoint and jwks_uri -- are the only outbound destinations vault42 takes from data rather than from configuration or a compiled-in literal. Protocol was already allowlisted by fetchableEndpoint (internal/oauth2/oidc.go:85). The domain, path and port half is now Policy.CheckDerived (internal/outbound/outbound.go:122), applied to all four before any of them is cached or used (internal/oauth2/oidc.go:188): an endpoint must name the issuer's own host, a subdomain of it on a label boundary, a loopback destination, or a host the operator listed in VAULT_OUTBOUND_ALLOWED_HOSTS. underDomain tests the suffix on a label boundary, so notokta.test is not admitted under okta.test. The rule needs no configuration and has no off switch: the nil Policy is the strict case. Policy.DialContext (internal/outbound/outbound.go:175) judges every address the destination resolved to and connects to the address it judged rather than re-resolving the name, which is what covers a name that resolves inward and a redirect that never passed through the endpoint check. cmd/vault/main.go:630 builds the deployment's Policy and installs it on every generic OIDC provider, asserted by TestVaultGuardsEveryOIDCProviderItBuilds. The refusal names the host and VAULT_OUTBOUND_ALLOWED_HOSTS, because the legitimate cross-domain provider is real. Previously accepted risk CR-17, now retired. No allowlist is applied to the six destinations that are compiled-in literals or operator-set URLs, deliberately: neither can be pointed anywhere by a request." + "notes": "The untrusted data this requirement names is the issuer's discovery document, and the four endpoints it supplies -- authorization_endpoint, token_endpoint, userinfo_endpoint and jwks_uri -- are the only outbound destinations vault42 takes from data rather than from configuration or a compiled-in literal. Protocol was already allowlisted by fetchableEndpoint (internal/oauth2/oidc.go:85). The domain, path and port half is now Policy.CheckDerived (internal/outbound/outbound.go:122), applied to all four before any of them is cached or used (internal/oauth2/oidc.go:188): an endpoint must name the issuer's own host, a subdomain of it on a label boundary, a loopback destination, or a host the operator listed in VAULT_OUTBOUND_ALLOWED_HOSTS. underDomain tests the suffix on a label boundary, so notokta.test is not admitted under okta.test. The rule needs no configuration and has no off switch: the nil Policy is the strict case. Policy.DialContext (internal/outbound/outbound.go:175) judges every address the destination resolved to and connects to the address it judged rather than re-resolving the name, which is what covers a name that resolves inward and a redirect that never passed through the endpoint check. cmd/vault/main.go:631 builds the deployment's Policy and installs it on every generic OIDC provider, asserted by TestVaultGuardsEveryOIDCProviderItBuilds. The refusal names the host and VAULT_OUTBOUND_ALLOWED_HOSTS, because the legitimate cross-domain provider is real. Previously accepted risk CR-17, now retired. No allowlist is applied to the six destinations that are compiled-in literals or operator-set URLs, deliberately: neither can be pointed anywhere by a request." }, { "standard": "OWASP ASVS", @@ -1864,7 +1864,7 @@ "TestNIST63B4_3_1_1_2_TheEnforcedPasswordFloorIsWhatTheDocsSay", "TestASVS_V2_1_1_PasswordMinLengthConfigured" ], - "notes": "Met. This requirement asks for 8 and strongly recommends 15; vault42 enforces 15. passwordMinLengthFloor (internal/config/config.go:1006) is 15 and a non-dev profile refuses to start below it (:616); the dev profile carries a lower floor of 8, which is still this requirement's own minimum. The evidence previously cited internal/config/config.go:453, a doc comment on the RecoveryRetentionPeriod field, which has nothing to do with password length." + "notes": "Met. This requirement asks for 8 and strongly recommends 15; vault42 enforces 15. passwordMinLengthFloor (internal/config/config.go:1021) is 15 and a non-dev profile refuses to start below it (:616); the dev profile carries a lower floor of 8, which is still this requirement's own minimum. The evidence previously cited internal/config/config.go:466, a doc comment on the RecoveryRetentionPeriod field, which has nothing to do with password length." }, { "standard": "OWASP ASVS", @@ -2711,7 +2711,7 @@ "tests": [ "TestASVS_V7_1_1_TheDocumentedTimeoutsAreTheEnforcedOnes" ], - "notes": "Met, all three clauses. Both timeouts are documented in docs/config.md and both are configurable: VAULT_MAX_SESSION_LIFETIME (default 720h) and VAULT_INACTIVITY_TIMEOUT (default 1h, internal/config/config.go:598). They are appropriate in combination -- the inactivity window is four access-token lifetimes wide, so a session in normal use never approaches it, and the absolute bound is the outer limit that a rotating client cannot slide. The deviation clause is satisfied and is the reason this row is not simply inherited from the two settings: docs/config.md states the NIST SP 800-63B-4 section 2.2.3 figures (24 hours overall, 1 hour of inactivity), says which one this deployment meets and which it deviates from, and says why. The entry previously cited 12 hours as the AAL2 figure, which is the withdrawn Rev 3 number; a merge reintroduced it after it was first corrected, and TestASVS_V7_1_1_TheDocumentedTimeoutsAreTheEnforcedOnes now fails if it comes back. That test also parses both documented defaults out of the published table and compares them with what config.Load produces, so the document cannot drift from the code in either direction." + "notes": "Met, all three clauses. Both timeouts are documented in docs/config.md and both are configurable: VAULT_MAX_SESSION_LIFETIME (default 720h) and VAULT_INACTIVITY_TIMEOUT (default 1h, internal/config/config.go:613). They are appropriate in combination -- the inactivity window is four access-token lifetimes wide, so a session in normal use never approaches it, and the absolute bound is the outer limit that a rotating client cannot slide. The deviation clause is satisfied and is the reason this row is not simply inherited from the two settings: docs/config.md states the NIST SP 800-63B-4 section 2.2.3 figures (24 hours overall, 1 hour of inactivity), says which one this deployment meets and which it deviates from, and says why. The entry previously cited 12 hours as the AAL2 figure, which is the withdrawn Rev 3 number; a merge reintroduced it after it was first corrected, and TestASVS_V7_1_1_TheDocumentedTimeoutsAreTheEnforcedOnes now fails if it comes back. That test also parses both documented defaults out of the published table and compares them with what config.Load produces, so the document cannot drift from the code in either direction." }, { "standard": "OWASP ASVS", @@ -4284,7 +4284,7 @@ "tests": [ "TestASVS_V12_1_1_EveryTLSConfigDeclaresAMinimumVersion" ], - "notes": "Redis TLS is available, and the Postgres DSN defaults to DBSSLMode \"require\" (internal/config/config.go:522). A profile configuring an SSL mode outside the encrypted set logs a security warning naming what travels in cleartext. Dev substitutes sslmode=disable deliberately, against a loopback database." + "notes": "Redis TLS is available, and the Postgres DSN defaults to DBSSLMode \"require\" (internal/config/config.go:535). A profile configuring an SSL mode outside the encrypted set logs a security warning naming what travels in cleartext. Dev substitutes sslmode=disable deliberately, against a loopback database." }, { "standard": "OWASP ASVS", @@ -4410,7 +4410,7 @@ "tests": [ "TestASVS_V12_2_1_PlaintextRequiresAnExplicitOverride" ], - "notes": "The chart ships a 259-line NetworkPolicy, enabled by default (charts/vault/values.yaml:724, networkPolicy.enabled), restricting egress to Postgres, Redis, DNS and SMTP." + "notes": "The chart ships a 259-line NetworkPolicy, enabled by default (charts/vault/values.yaml:731, networkPolicy.enabled), restricting egress to Postgres, Redis, DNS and SMTP." }, { "standard": "OWASP ASVS", @@ -5563,7 +5563,7 @@ "TestNIST_NoCompositionRules", "TestNIST_NoForcedPasswordExpiration" ], - "notes": "Subscriber-chosen passwords, no composition rules, no periodic rotation. Section 3.1.1.1 requires a verifier to accept memorized secrets of at least 8 characters; the lowest floor any profile carries is devPasswordMinLengthFloor = 8 (internal/config/config.go:1014), and every other profile enforces 15. The evidence previously cited internal/config/config.go:453, a doc comment on an unrelated field." + "notes": "Subscriber-chosen passwords, no composition rules, no periodic rotation. Section 3.1.1.1 requires a verifier to accept memorized secrets of at least 8 characters; the lowest floor any profile carries is devPasswordMinLengthFloor = 8 (internal/config/config.go:1029), and every other profile enforces 15. The evidence previously cited internal/config/config.go:466, a doc comment on an unrelated field." }, { "standard": "NIST SP 800-63B-4", @@ -5585,7 +5585,7 @@ "TestNIST_PasswordNoTruncation", "TestNIST_SaltLength" ], - "notes": "Met. Rev 4 raises the single-factor password floor to 15 characters, and 15 is now the enforced floor and not merely the shipped default: passwordMinLengthFloor (internal/config/config.go:1006) equals the default (:453), and a deployment configuring less is refused at startup (:616). The dev profile is no longer exempt from having a floor at all -- it has a lower one, devPasswordMinLengthFloor = 8 (internal/config/config.go:1014), which is itself the figure section 3.1.1.1 requires a verifier to accept, so no profile now accepts a four-character password. Salting, hashing with Argon2id, no truncation, Unicode acceptance and breach-corpus blocking are separately tested. This row was an accepted risk (CR-31) for exactly as long as the floor was 8: TestNIST63B4_3_1_1_2_TheEnforcedPasswordFloorIsWhatTheDocsSay reads both numbers out of config.go and fired on the merge that raised it." + "notes": "Met. Rev 4 raises the single-factor password floor to 15 characters, and 15 is now the enforced floor and not merely the shipped default: passwordMinLengthFloor (internal/config/config.go:1021) equals the default (:453), and a deployment configuring less is refused at startup (:616). The dev profile is no longer exempt from having a floor at all -- it has a lower one, devPasswordMinLengthFloor = 8 (internal/config/config.go:1029), which is itself the figure section 3.1.1.1 requires a verifier to accept, so no profile now accepts a four-character password. Salting, hashing with Argon2id, no truncation, Unicode acceptance and breach-corpus blocking are separately tested. This row was an accepted risk (CR-31) for exactly as long as the floor was 8: TestNIST63B4_3_1_1_2_TheEnforcedPasswordFloorIsWhatTheDocsSay reads both numbers out of config.go and fired on the merge that raised it." }, { "standard": "NIST SP 800-63B-4", @@ -8017,7 +8017,7 @@ "TestK8sPSS_Restricted_NoWorkloadTakesAHostNamespaceOrPrivilege", "TestK8sPSS_Restricted_VaultPlaneWorkloadsMeetTheProfile" ], - "notes": "Met: no workload takes a host namespace in a default render. hostPID and hostIPC are set nowhere, and adminGateway.hostNetwork now defaults to false (charts/vault/values.yaml:952); the admin-gateway template only emits hostNetwork when an operator opts in (charts/vault/templates/admin-gateway.yaml:52), and the chart makes that choice explicit rather than silent. This was accepted risk CR-33 while the default was true. Scope: every workload the chart deploys, with no exclusions. The bundled PostgreSQL, the honeypot PostgreSQL and Mailpit were excluded by name while they did not meet the profile; all three have since been hardened and the exclusion is gone, which is what TestK8sPSS_Restricted_ThereAreNoDeviationsLeft now asserts, having replaced the exclusion tripwire that fired on the day the last of them was hardened." + "notes": "Met: no workload takes a host namespace in a default render. hostPID and hostIPC are set nowhere, and adminGateway.hostNetwork now defaults to false (charts/vault/values.yaml:959); the admin-gateway template only emits hostNetwork when an operator opts in (charts/vault/templates/admin-gateway.yaml:52), and the chart makes that choice explicit rather than silent. This was accepted risk CR-33 while the default was true. Scope: every workload the chart deploys, with no exclusions. The bundled PostgreSQL, the honeypot PostgreSQL and Mailpit were excluded by name while they did not meet the profile; all three have since been hardened and the exclusion is gone, which is what TestK8sPSS_Restricted_ThereAreNoDeviationsLeft now asserts, having replaced the exclusion tripwire that fired on the day the last of them was hardened." }, { "standard": "Kubernetes PSS", @@ -8609,11 +8609,11 @@ "CR-16": "Closed. A failed authorization decision now reaches the audit log: adminapi.RBACCheck writes an admin_authz_denied record on a permission denial. Asserted by TestASVS_V16_3_2_RBACDenialsAreAudited. Not to be confused with docs/security.md AR-16, which is a different and open risk.", "CR-19": "Closed. The login-outcome enumeration oracle: account_locked, the import-claim path, banned and disabled now all answer 401 invalid_credentials without a valid password. Asserted by TestASVS_V3_7_1_AccountLockoutPreventsEnumeration.", "CR-27": "Closed. Both Content-Security-Policy strings now declare object-src 'none' and base-uri 'none' (internal/middleware/security_headers.go:18 and :19), which is the minimum ASVS V3.4.3 names. Asserted by TestASVS_V3_4_3_TheCSPMatchesWhatTheRegisterClaims, which was written to fail on closure and did.", - "CR-31": "Closed. The 15-character password minimum is now the enforced floor and not only the shipped default: passwordMinLengthFloor (internal/config/config.go:1006) is 15 and a non-dev profile refuses to start below it (:622). The dev profile is no longer exempt from having a floor -- it carries a lower one, devPasswordMinLengthFloor = 8 (:812), which is itself the figure SP 800-63B-4 section 3.1.1.1 requires a verifier to accept. While this risk was open the register, docs/COMPLIANCE.md and README.md all said 15 was 'the minimum vault42 has enforced since 0.4'; it was the default and the floor was 8, and the row was downgraded to an accepted risk rather than the claim being softened. Asserted by TestNIST63B4_3_1_1_2_TheEnforcedPasswordFloorIsWhatTheDocsSay, which reads both numbers out of config.go and fired on the merge that raised the floor.", + "CR-31": "Closed. The 15-character password minimum is now the enforced floor and not only the shipped default: passwordMinLengthFloor (internal/config/config.go:1021) is 15 and a non-dev profile refuses to start below it (:622). The dev profile is no longer exempt from having a floor -- it carries a lower one, devPasswordMinLengthFloor = 8 (:812), which is itself the figure SP 800-63B-4 section 3.1.1.1 requires a verifier to accept. While this risk was open the register, docs/COMPLIANCE.md and README.md all said 15 was 'the minimum vault42 has enforced since 0.4'; it was the default and the floor was 8, and the row was downgraded to an accepted risk rather than the claim being softened. Asserted by TestNIST63B4_3_1_1_2_TheEnforcedPasswordFloorIsWhatTheDocsSay, which reads both numbers out of config.go and fired on the merge that raised the floor.", "CR-32": "Closed. .github/dependabot.yml configures automated dependency updates. While this risk was open the reasoning was that a scheduled scanner reports a vulnerable dependency and does not raise the pull request that fixes it, so nightly govulncheck and Trivy could not stand in for an update tool. Asserted by TestSSDF_800_218_DependencyUpdateAutomationIsAutomated.", "CR-25": "Closed, both halves. DPoP: cnf.jkt is set at issuance on the access, rotated and challenge paths (internal/service/token.go:276, :278) and enforced by a constant-time thumbprint comparison (internal/middleware/dpop.go:110); the attack suite's tripwire was inverted so it now fails if the binding is dropped. Memory zeroing: the decrypted blob plaintext and label are wiped (internal/service/blob.go:372, :399, :441), which was the remaining residual after the keystore PEM was found already wiped. Refresh tokens are still not bound and there is no DPoP-Nonce; both are recorded in the requirement row rather than carried as a risk, because neither is a gap against the claim now made. What keeps it closed: TestRFC9700_4_10_1_SenderConstrainedDPoP holds the binding, and TestBlobServiceZeroesEveryDecryptedBuffer holds the wipe.", "CR-33": "Closed. adminGateway.hostNetwork now defaults to false, so a default render takes no host namespace and the chart meets the restricted profile without an exemption. While the risk was open the argument was that hostNetwork was load-bearing for LocalOnly, which requires the node's loopback; an operator who still wants that posture opts in, on a namespace whose policy permits it, and the chart says so at the point of choice. What keeps it closed: TestK8sPSS_Restricted_NoWorkloadTakesAHostNamespaceOrPrivilege fails on a default render that takes a host namespace, and TestK8sPSS_Restricted_ThereAreNoDeviationsLeft fails if an exemption is reintroduced.", - "CR-23": "Closed. internal/email/smtp.go:111 replaced net/smtp.SendMail with an explicit conversation: STARTTLS when the server advertises it, ErrSMTPNoSTARTTLS when it does not, and tls.VersionTLS12 rather than the Go default. The opt-out, VAULT_SMTP_ALLOW_PLAINTEXT, is refused outside the dev profile unless the SMTP host is loopback (internal/config/config.go:714). ASVS V12.3.1 did not become Met, because the SMTP relay was never the only unencrypted outbound link: the Redis cache connection has no TLS option at all, which the row had not recorded. That is now CR-34. What keeps it closed: TestSendRefusesAServerThatDoesNotOfferSTARTTLS and TestSendUpgradesWheneverSTARTTLSIsAdvertised drive a fake relay through both branches, and TestASVS_V12_2_1_PlaintextRequiresAnExplicitOverride holds the config-side refusal.", + "CR-23": "Closed. internal/email/smtp.go:111 replaced net/smtp.SendMail with an explicit conversation: STARTTLS when the server advertises it, ErrSMTPNoSTARTTLS when it does not, and tls.VersionTLS12 rather than the Go default. The opt-out, VAULT_SMTP_ALLOW_PLAINTEXT, is refused outside the dev profile unless the SMTP host is loopback (internal/config/config.go:729). ASVS V12.3.1 did not become Met, because the SMTP relay was never the only unencrypted outbound link: the Redis cache connection has no TLS option at all, which the row had not recorded. That is now CR-34. What keeps it closed: TestSendRefusesAServerThatDoesNotOfferSTARTTLS and TestSendUpgradesWheneverSTARTTLSIsAdvertised drive a fake relay through both branches, and TestASVS_V12_2_1_PlaintextRequiresAnExplicitOverride holds the config-side refusal.", "CR-21": "Closed, both rows. VaultClaims carries acr, amr and auth_time, and AALForMethods has non-test callers on every user-authenticating issuance path: the password login and the MFA completion through internal/service/auth.go, and the OAuth callback through internal/handler/oauth.go. ASVS V6.8.4 is Met on the requirement's fallback clause -- a provider assertion is assumed to be the minimum strength mechanism and never counts as a second factor -- and V10.3.4 is Met on middleware.Confirmed, which checks authentication recentness against the jti of the presented access token. Asserted by TestASVS_V6_8_4_FederatedLoginAssumesTheMinimumAuthenticationStrength and TestASVS_V10_3_4_RecentnessIsVerifiedAgainstThePresentedAccessToken. The trap this risk recorded was avoided rather than hit: MFAStatus.Methods is not what reaches AALForMethods, because MFACompletion carries the first factor across on the challenge token and its methods() concatenates that with the second.", "CR-17": "Closed, both rows. The four endpoints an OIDC discovery document supplies were the whole of vault42's server-side request forgery surface and had only a protocol check. Policy.CheckDerived (internal/outbound/outbound.go:122) now holds them to the issuer's own domain, a loopback destination, or a host the operator named in VAULT_OUTBOUND_ALLOWED_HOSTS, applied at internal/oauth2/oidc.go:185 before the document is cached; Policy.DialContext (internal/outbound/outbound.go:175) judges the addresses those hosts resolve to and connects to the address it judged, which is what covers DNS rebinding and a redirect that never passed through the endpoint check. ASVS V1.3.6 is Met on the destination allowlist and OWASP API7:2023 on what it protects, asserted by TestASVS_V1_3_6_DiscoverySuppliedDestinationsAreCheckedAgainstAnAllowlist and TestOWASP_API7_2023_AnOutboundCallCannotReachInsideTheDeployment. Two things this risk recorded did not survive the closure. Its compensating control claimed the chart admitted no egress to 443 at all; d124a06 opened 443 to any destination by default so the HIBP breach check would stop failing open silently, so the network-layer half is now the weaker one and the application-layer check is what carries these rows. And its cost-of-closing named a registrable-domain constraint; the shipped rule is the stricter same-or-subdomain test, which needs no public suffix list and no new dependency, at the price of an operator list entry for a provider whose endpoints legitimately span domains -- accounts.google.com serving its keys from www.googleapis.com is the case that costs one.", "CR-26": "Closed, all three rows. attachmentDisposition (internal/handler/blob.go:434) sets Content-Disposition: attachment with a server-chosen filename on both download paths (internal/handler/blob.go:221 and :315), and docs/api.md gained the 'How stored objects are made safe to download' section V5.1.1's second clause asks for. ASVS V3.2.1 is Met on the disposition type, V5.4.1 on the filename parameter and V5.1.1 on the documentation, each with its own test rather than one cited three times, because the three requirements turn on three different halves of the same change. The filename is contentDispositionFilename's reduction of the blob's own reference rather than the reference itself: the estimate in this risk's cost-of-closing was 'one header on each of the two download paths', and it was one line short, because DownloadNamed reads its name straight out of the route and never runs validRefName, so the header could not borrow the upload path's charset guarantee. What keeps it closed: TestASVS_V3_2_1_ABlobDownloadCarriesTheAttachmentDispositionType, TestASVS_V5_4_1_ABlobDownloadSpecifiesAFilenameChosenByTheServer and TestASVS_V5_1_1_TheDocumentationSaysHowStoredObjectsAreMadeSafeToDownload, one per row rather than one cited three times, for the reason above.", diff --git a/docs/config.md b/docs/config.md index 9941e480..688a058b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -418,8 +418,9 @@ controls that bound it are in [`spec.md` section 6.5](spec.md#65-subject-asserti | `VAULT_MINT_MAX_TTL` | duration | `5m` | No | Ceiling on a caller-requested lifetime, itself capped at 15m in code. A request above the ceiling is **refused, not clamped**, so a misconfigured caller is visible rather than silently downgraded. Minted tokens cannot be revoked, so the lifetime is the only bound on a leaked one. | | `VAULT_MINT_ROLES` | list | *(empty)* | No | Comma-separated allow-list of roles a minted token may carry. Empty means no role may be minted. The admin-reserved names are refused at startup regardless of what is listed here. | | `VAULT_MINT_SCOPES` | list | *(empty)* | No | Comma-separated allow-list of scopes a minted token may carry. Empty means no scope may be minted. Capability scopes such as `kms:unwrap` and `mint:token` are refused regardless. | +| `VAULT_MINT_ALLOW_EMAIL` | bool | `false` | No | Permits an `email` claim on a minted token. Every other minted claim is a name vault42 issued or allow-listed; an email is a caller-supplied identifier for a subject vault42 has never heard of, so enabling this says the operator trusts this client to assert who its users are. A request carrying an email while this is off is **refused, not stripped**. The claim is never verified and a relying party must not read it as proof of address ownership. | -The Helm chart exposes all six as the `mint.*` block, each at the default above, so an install that +The Helm chart exposes all seven as the `mint.*` block, each at the default above, so an install that does not opt in renders the same environment it did before the block existed. They are exposed together because none of them works alone: `mint.enabled` without `mint.audience` refuses to start, and `mint.enabled` with empty allow-lists mounts an endpoint that grants nothing. The chart fails the @@ -888,6 +889,7 @@ Key Helm values and their corresponding env vars: | `mint.maxTTL` | `VAULT_MINT_MAX_TTL` | | `mint.allowedRoles` | `VAULT_MINT_ROLES` (comma-joined) | | `mint.allowedScopes` | `VAULT_MINT_SCOPES` (comma-joined) | +| `mint.allowEmail` | `VAULT_MINT_ALLOW_EMAIL` | | `sessions.maxPerUser` | `VAULT_MAX_SESSIONS_PER_USER` | | `sessions.maxLifetime` | `VAULT_MAX_SESSION_LIFETIME` | | `sessions.inactivityTimeout` | `VAULT_INACTIVITY_TIMEOUT` | diff --git a/internal/config/config.go b/internal/config/config.go index 22921183..e38cd2a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -354,6 +354,19 @@ type Config struct { // as kms:unwrap and mint:token are refused regardless of configuration. MintAllowedScopes []string + // MintAllowEmail lets a mint request carry an email claim + // (VAULT_MINT_ALLOW_EMAIL). Off by default, and off is the right default: + // every other minted claim is a name vault42 either issued or allow-listed, + // whereas an email is a caller-supplied identifier for a subject vault42 has + // never heard of. Turning it on says the operator trusts this client to + // assert who its users are, which is a larger statement than trusting it to + // name a subject id, and it should be made deliberately rather than + // inherited from a default. Requests carrying an email while this is off are + // refused, not silently stripped -- the same reasoning as the role + // allow-list: a signing oracle that quietly issues something other than what + // was asked for hides the misconfiguration that produced the request. + MintAllowEmail bool + // SvcDocEnabled mounts the service-scoped JSON document store (VAULT_SVCDOC_ENABLED). // Off by default: it is new surface reachable by every existing client-credentials // holder, so enabling it is an explicit operator decision. @@ -524,6 +537,8 @@ func Load() (*Config, error) { MintTokenTTL: envDuration("VAULT_MINT_TOKEN_TTL", 5*time.Minute), MintMaxTTL: envDuration("VAULT_MINT_MAX_TTL", 5*time.Minute), + MintAllowEmail: envBool("VAULT_MINT_ALLOW_EMAIL"), + SvcDocEnabled: envBool("VAULT_SVCDOC_ENABLED"), SvcDocSharedEnabled: envBool("VAULT_SVCDOC_SHARED_ENABLED"), SvcDocMaxSize: envInt("VAULT_SVCDOC_MAX_SIZE", 64*1024), diff --git a/internal/config/envcheck.go b/internal/config/envcheck.go index 07540229..93e8be47 100644 --- a/internal/config/envcheck.go +++ b/internal/config/envcheck.go @@ -56,6 +56,7 @@ var boolEnvVars = []string{ "VAULT_KEY_ROTATION_DB", "VAULT_METRICS_ENABLED", "VAULT_MFA_REQUIRED", + "VAULT_MINT_ALLOW_EMAIL", "VAULT_MINT_ENABLED", "VAULT_OUTBOUND_ALLOW_PRIVATE", "VAULT_RATE_LIMIT_ENABLED", diff --git a/internal/config/mint_test.go b/internal/config/mint_test.go index a88e8623..73dc11f6 100644 --- a/internal/config/mint_test.go +++ b/internal/config/mint_test.go @@ -152,3 +152,25 @@ func TestValidateMintAudience(t *testing.T) { } }) } + +// The email opt-in defaults off, and the default is the whole point: it is the +// one minted claim that asserts who a subject is rather than what they may do. +func TestLoadMintAllowEmailDefaultsOff(t *testing.T) { + t.Setenv("VAULT_MINT_ALLOW_EMAIL", "") + c, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.MintAllowEmail { + t.Fatal("MintAllowEmail defaulted on") + } + + t.Setenv("VAULT_MINT_ALLOW_EMAIL", "true") + c, err = Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if !c.MintAllowEmail { + t.Fatal("VAULT_MINT_ALLOW_EMAIL=true did not take") + } +} diff --git a/internal/crypto/jwt.go b/internal/crypto/jwt.go index d6a7a233..1e342e46 100644 --- a/internal/crypto/jwt.go +++ b/internal/crypto/jwt.go @@ -59,6 +59,22 @@ type VaultClaims struct { // began with a password or with an upstream identity provider. It is not an // authorization claim and no access token carries it. Factors []string `json:"factors,omitempty"` + + // Email is an assertion the caller made, not a fact vault42 established. + // + // It exists for one reason: a relying party that already has its own user + // table keyed by email cannot use a token that names only an opaque + // subject. BeOn3's storage worker derives every avatar's owner key as + // GUID(SHA256(userId+email)), so a missing email there does not fail, it + // silently detaches the object from its owner. + // + // It is set only on the /mint path, only when VAULT_MINT_ALLOW_EMAIL is on, + // and vault42 never looks the address up: /mint asserts subjects it has + // never heard of by design, and an email is the same kind of claim about + // the same unknown subject. A reader must not treat it as verified. The + // email on a *login* token would be a different thing entirely, and there + // is not one -- no vault42-issued access token carries this claim. + Email string `json:"email,omitempty"` } // Confirmation holds DPoP proof-of-possession binding (RFC 9449). diff --git a/internal/handler/fuzz_oauth_test.go b/internal/handler/fuzz_oauth_test.go index df6f694b..ee16ce79 100644 --- a/internal/handler/fuzz_oauth_test.go +++ b/internal/handler/fuzz_oauth_test.go @@ -54,6 +54,13 @@ func FuzzMintRequestJSON(f *testing.F) { f.Add([]byte(`[]`)) f.Add([]byte(`null`)) f.Add([]byte(``)) + // Seeds carrying an email. Without one the email property below never runs: + // the fuzzer mutates from the corpus, no seed had the key, and there is no + // testdata corpus for this target, so the whole branch executed zero times + // while the subject branch beside it executed thousands. A property nothing + // enters is not a property. + f.Add([]byte(`{"subject":"user-1","email":" Alice@Example.COM "}`)) + f.Add([]byte(`{"subject":"user-1","email":"not-an-address"}`)) f.Add([]byte(`{"subject":"u","extra":true}`)) f.Add([]byte(`{"suBjeCt":"user-1"}`)) f.Add([]byte(`{"SUBJECT":"user-1"}`)) @@ -114,6 +121,36 @@ func FuzzMintRequestJSON(f *testing.F) { } } + // Same contract as the subject, and checked rather than discarded for + // the same reason. checkEmail skips the validator for an absent email, + // so the fuzzer skips it too: "" is the one decoded value the handler + // never hands over. + if parsed.Email != "" { + normalized, emailErr := service.ValidateMintEmail(parsed.Email) + if emailErr != nil { + if !errors.Is(emailErr, service.ErrMintEmailInvalid) { + t.Fatalf("ValidateMintEmail(%q) = %v, want ErrMintEmailInvalid", parsed.Email, emailErr) + } + if normalized != "" { + t.Fatalf("ValidateMintEmail(%q) rejected the address but still returned %q", parsed.Email, normalized) + } + } else { + // A relying party keys its own table on this string. If + // normalisation is not idempotent, two spellings of one address + // reach two callers as two different values, and that table + // grows two rows for one person -- which is the whole reason the + // validator normalises instead of only validating. + if normalized != strings.ToLower(strings.TrimSpace(normalized)) { + t.Fatalf("ValidateMintEmail(%q) = %q, which is not lower-cased and trimmed", parsed.Email, normalized) + } + again, againErr := service.ValidateMintEmail(normalized) + if againErr != nil || again != normalized { + t.Fatalf("ValidateMintEmail is not idempotent: %q -> %q -> (%q, %v)", + parsed.Email, normalized, again, againErr) + } + } + } + d, ttlErr := service.MintTTLFromSeconds(parsed.TTLSeconds) if parsed.TTLSeconds < 0 || parsed.TTLSeconds > 900 { if ttlErr == nil { @@ -134,7 +171,8 @@ func mintRequestJSONField(k string) bool { case strings.EqualFold(k, "subject"), strings.EqualFold(k, "roles"), strings.EqualFold(k, "scopes"), - strings.EqualFold(k, "ttl_seconds"): + strings.EqualFold(k, "ttl_seconds"), + strings.EqualFold(k, "email"): return true default: return false diff --git a/internal/handler/mint.go b/internal/handler/mint.go index 2b3e3e46..c68317b9 100644 --- a/internal/handler/mint.go +++ b/internal/handler/mint.go @@ -46,6 +46,11 @@ type MintRequestBody struct { // misconfigured caller finds out now rather than when tokens expire // mid-flight. TTLSeconds int `json:"ttl_seconds,omitempty"` + + // Email is optional and refused unless VAULT_MINT_ALLOW_EMAIL is set. It is + // an assertion about a subject vault42 has never heard of, exactly like + // Subject, and it is not verified against anything. + Email string `json:"email,omitempty"` } // MintResponse is the POST /mint response. @@ -76,6 +81,9 @@ type MintResponse struct { Roles []string `json:"roles,omitempty"` // Scopes is the granted set. Omitted when none were requested. Scopes []string `json:"scopes,omitempty"` + // Email echoes the assertion the caller made, so a client can see what it + // actually got rather than what it meant to send. + Email string `json:"email,omitempty"` // KID is the signing-key id, resolvable against GET /.well-known/jwks.json. KID string `json:"kid"` // JTI is the token's unique id. It is also written on the token_minted @@ -148,6 +156,7 @@ func (h *MintHandler) Mint(w http.ResponseWriter, r *http.Request) { Roles: req.Roles, Scopes: req.Scopes, TTL: ttl, + Email: req.Email, // The authenticated client, never anything from the body. MintRequestBody // has no field for this, so a caller cannot name a different tenant's // client as the one that spoke. @@ -171,6 +180,7 @@ func (h *MintHandler) Mint(w http.ResponseWriter, r *http.Request) { Issuer: result.Issuer, Roles: result.Roles, Scopes: result.Scopes, + Email: result.Email, KID: result.KID, JTI: result.JTI, }) @@ -234,6 +244,10 @@ func mintErrorCode(err error) (int, string) { return http.StatusForbidden, "scope_not_permitted" case errors.Is(err, service.ErrMintTTLInvalid): return http.StatusBadRequest, "invalid_ttl" + case errors.Is(err, service.ErrMintEmailNotPermitted): + return http.StatusForbidden, "email_not_permitted" + case errors.Is(err, service.ErrMintEmailInvalid): + return http.StatusBadRequest, "invalid_email" case errors.Is(err, service.ErrMintUnavailable): return http.StatusServiceUnavailable, "server_busy" default: diff --git a/internal/handler/mint_test.go b/internal/handler/mint_test.go index 74dbad25..0ebfe487 100644 --- a/internal/handler/mint_test.go +++ b/internal/handler/mint_test.go @@ -598,3 +598,121 @@ func TestMintHandler_AuditsASigningFailure(t *testing.T) { t.Errorf("user id = %q, want the asserted subject", captured[0].UserID) } } + +// The opt-in is refused at the HTTP edge with a code of its own, so a caller +// pointed at an instance that does not allow the claim learns that, rather than +// receiving a token that silently says less than it asked for and failing later +// inside the relying party. +func TestMintHandler_EmailWithoutTheOptInIsRefused(t *testing.T) { + h := newMintTestHandler(t, nil) // AllowEmail defaults off + rec := httptest.NewRecorder() + h.Mint(rec, withServiceClaims(mintRequest(t, MintRequestBody{ + Subject: "user-1", Email: "alice@example.com", + }), "client-1", []string{MintScope})) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "email_not_permitted") { + t.Fatalf("body = %s, want email_not_permitted", rec.Body.String()) + } +} + +func TestMintHandler_AnInvalidEmailIsABadRequest(t *testing.T) { + h := newMintTestHandler(t, func(c *service.MintConfig) { c.AllowEmail = true }) + rec := httptest.NewRecorder() + h.Mint(rec, withServiceClaims(mintRequest(t, MintRequestBody{ + Subject: "user-1", Email: "Alice ", + }), "client-1", []string{MintScope})) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "invalid_email") { + t.Fatalf("body = %s, want invalid_email", rec.Body.String()) + } +} + +// The response echoes the asserted address so a client can see what it actually +// got. It is echoed normalized, which is the value that went into the claim. +func TestMintHandler_TheResponseEchoesTheAssertedEmail(t *testing.T) { + h := newMintTestHandler(t, func(c *service.MintConfig) { c.AllowEmail = true }) + rec := httptest.NewRecorder() + h.Mint(rec, withServiceClaims(mintRequest(t, MintRequestBody{ + Subject: "user-1", Email: " Alice@Example.COM ", + }), "client-1", []string{MintScope})) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + var resp MintResponse + decodeResponse(t, rec, &resp) + if resp.Email != "alice@example.com" { + t.Fatalf("response email = %q", resp.Email) + } + payload := mintedTokenPayload(t, resp.AccessToken) + if payload["email"] != "alice@example.com" { + t.Fatalf("claim email = %v", payload["email"]) + } +} + +// The audit row deliberately does not carry the address. Audit entries outlive +// an erasure, and this is caller-asserted personal data that vault42 never +// verified and holds no other record of -- writing it here would create one, +// in the one table designed to survive the subject asking to be forgotten. +func TestMintHandler_TheAuditRowDoesNotCarryTheEmail(t *testing.T) { + var captured []*model.AuditEntry + auditRepo := &mocks.MockAuditRepo{ + InsertFn: func(_ context.Context, entry *model.AuditEntry) error { + captured = append(captured, entry) + return nil + }, + } + svc, err := service.NewMintService(mintHandlerSigner(t), service.MintConfig{ + Issuer: mintHandlerIssuer, Audience: mintHandlerAudience, + DefaultTTL: time.Minute, MaxTTL: time.Minute, AllowEmail: true, + }, nil) + if err != nil { + t.Fatalf("NewMintService: %v", err) + } + h := NewMintHandler(svc, audit.NewLogger(auditRepo, 0)) + + rec := httptest.NewRecorder() + h.Mint(rec, withServiceClaims(mintRequest(t, MintRequestBody{ + Subject: "user-1", Email: "alice@example.com", + }), mintHandlerClient, []string{MintScope})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + + if len(captured) == 0 { + t.Fatal("no audit entry recorded") + } + // Marshaled whole, not walked field by field. model.AuditEntry carries + // UserID, ClientID, IP, UserAgent and DeviceID outside Metadata, and + // Metadata itself holds interface{} -- so a loop over string values in one + // map cannot fail for the reason this test is named for. The address landing + // in UserID would have passed it. This is the idiom + // blob_audit_privacy_test.go uses for the same question. + // + // Marshaling alone is not enough either, and the earlier version of this + // test said FingerprintHash was covered by it when it is exactly the field + // that is not: it is tagged json:"-", so json.Marshal never emits it. + // Passing the address as the fpHash argument left this green while writing + // it to audit.audit_log. Anything hidden from the encoder has to be asserted + // by hand, so it is, below. + for _, e := range captured { + serialized, err := json.Marshal(e) + if err != nil { + t.Fatalf("marshal audit entry: %v", err) + } + if strings.Contains(string(serialized), "alice@example.com") { + t.Errorf("audit entry %q carries the asserted email: %s", e.EventType, serialized) + } + // The json:"-" fields, which the marshal above cannot see. + if strings.Contains(e.FingerprintHash, "alice@example.com") { + t.Errorf("audit entry %q carries the asserted email in FingerprintHash, which is "+ + "tagged json:\"-\" and so is invisible to the check above", e.EventType) + } + } +} diff --git a/internal/service/mint.go b/internal/service/mint.go index 9c926d27..6dc3c7bb 100644 --- a/internal/service/mint.go +++ b/internal/service/mint.go @@ -5,10 +5,12 @@ import ( "errors" "fmt" "regexp" + "strings" "time" vaultcrypto "github.com/42-v/vault42/internal/crypto" vjwt "github.com/42-v/vault42/internal/jwt" + "github.com/42-v/vault42/internal/sanitize" "github.com/42-v/vault42/internal/seed" ) @@ -148,6 +150,14 @@ var ( ErrMintTTLInvalid = errors.New("invalid mint ttl") // ErrMintUnavailable is returned when no signing key is available. ErrMintUnavailable = errors.New("mint signing key unavailable") + // ErrMintEmailNotPermitted is returned when a request carries an email and + // VAULT_MINT_ALLOW_EMAIL is off. Refused rather than stripped, for the same + // reason a disallowed role is: the caller asked for it in a request that + // would otherwise be granted. + ErrMintEmailNotPermitted = errors.New("mint email not permitted") + // ErrMintEmailInvalid is returned when a request carries an email that + // sanitize.Email rejects. + ErrMintEmailInvalid = errors.New("invalid mint email") ) // SigningKeyProvider returns the currently active signing key and its kid. It @@ -180,6 +190,9 @@ type MintConfig struct { // AllowedScopes is the exhaustive set of scopes that may be minted. Empty // means no scope may be minted. AllowedScopes []string + // AllowEmail permits an email claim on a minted token. Off by default; a + // request carrying an email while this is off is refused. + AllowEmail bool } // MintService issues subject-assertion tokens on behalf of a trusted service. @@ -200,6 +213,9 @@ type MintRequest struct { // allow-lists. Roles []string Scopes []string + // Email is optional and requires MintConfig.AllowEmail. Like Subject it is + // asserted by the caller and never looked up. + Email string // TTL is optional; zero means MintConfig.DefaultTTL. TTL time.Duration // MintedBy is the authenticated client asking for the assertion. It reaches @@ -217,6 +233,7 @@ type MintResult struct { Subject string Roles []string Scopes []string + Email string Audience string Issuer string JTI string @@ -299,6 +316,11 @@ func (s *MintService) Mint(req MintRequest) (*MintResult, error) { s.rejected() return nil, err } + email, err := s.checkEmail(req.Email) + if err != nil { + s.rejected() + return nil, err + } ttl := req.TTL if ttl == 0 { @@ -344,6 +366,9 @@ func (s *MintService) Mint(req MintRequest) (*MintResult, error) { // the two meanings stay apart. MintedBy: req.MintedBy, TokenType: MintedTokenType, + // Empty unless the operator turned AllowEmail on and the caller sent + // one; omitempty keeps the claim off every other minted token. + Email: email, } token, err := vaultcrypto.SignToken(claims, key, kid) @@ -359,6 +384,7 @@ func (s *MintService) Mint(req MintRequest) (*MintResult, error) { Subject: req.Subject, Roles: roles, Scopes: scopes, + Email: email, Audience: s.cfg.Audience, Issuer: s.cfg.Issuer, JTI: jti, @@ -410,6 +436,64 @@ func (s *MintService) checkScopes(requested []string) ([]string, error) { return out, nil } +// checkEmail enforces the email opt-in and the address format. +// +// Two separate refusals, because they are two different operator mistakes. An +// email sent to an instance with AllowEmail off is a client configured against +// the wrong deployment, or an operator who has not yet decided to trust this +// client with the assertion. A malformed address is a bug in the caller. Both +// refuse the whole request rather than dropping the claim, so neither one +// reaches a relying party as a token that quietly says less than it was asked +// to. +func (s *MintService) checkEmail(email string) (string, error) { + if email == "" { + return "", nil + } + if !s.cfg.AllowEmail { + return "", ErrMintEmailNotPermitted + } + normalized, err := ValidateMintEmail(email) + if err != nil { + return "", err + } + return normalized, nil +} + +// ValidateMintEmail normalizes and validates a caller-asserted email. +// +// Exported so FuzzMintRequestJSON can call it directly and assert the contract +// -- a rejection is ErrMintEmailInvalid and returns nothing, an acceptance is +// lower-cased, trimmed and idempotent -- rather than exercising only the handler +// that wraps it. +// +// Not quite the parity with ValidateMintSubject an earlier version of this +// comment claimed, and the difference is worth knowing. The subject has a +// dedicated target under tests/fuzz, which is the only directory CI runs with +// -fuzz. FuzzMintRequestJSON runs there as an ordinary test, so in CI this +// validator is exercised by its seeds rather than by mutation. The seeds are +// what make that worth anything: before they existed no seed carried an email +// key, so the branch executed zero times. +// +// Normalisation is lower-case and trimmed, which is what every other email +// entry point in the tree does before it stores or looks up an address +// (internal/service/auth.go, internal/handler/oauth.go, +// internal/adminapi/import.go all hand-roll the same two calls). A minted email +// is never looked up here, but a relying party will look it up in its own +// table, and handing two spellings of one address to two callers is how that +// table grows two rows for one person. +// +// sanitize.Email is deliberately a validator and not a normalizer: it accepts +// homoglyphs and bidi controls as distinct byte strings rather than folding +// them together. That contract is pinned by tests/attack/unicode_email_test.go +// and is not weakened here. +func ValidateMintEmail(email string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(email)) + if !sanitize.Email(normalized) { + return "", ErrMintEmailInvalid + } + return normalized, nil +} + func (s *MintService) rejected() { if s.metrics != nil { s.metrics.RecordMintRejected() diff --git a/internal/service/mint_test.go b/internal/service/mint_test.go index 3f3f4f46..972c9a09 100644 --- a/internal/service/mint_test.go +++ b/internal/service/mint_test.go @@ -620,3 +620,105 @@ func TestMint_RecordsMetrics(t *testing.T) { t.Fatalf("metrics: issued=%d rejected=%d", m.issued, m.rejected) } } + +// The email claim is the one minted value that is not a name vault42 issued or +// allow-listed: it is a caller-supplied identifier for a subject vault42 has +// never heard of. So it is off unless an operator turned it on, and the request +// is refused rather than quietly stripped -- the same call the role allow-list +// makes, for the same reason. +func TestMint_EmailRequiresTheOptIn(t *testing.T) { + cfg := mintTestConfig() + cfg.AllowEmail = false + svc := newMintService(t, cfg) + + if _, err := svc.Mint(MintRequest{Subject: "user-1", Email: "alice@example.com"}); !errors.Is(err, ErrMintEmailNotPermitted) { + t.Fatalf("email accepted with AllowEmail off: %v", err) + } + // Without an email the same configuration mints normally, so the refusal is + // about the claim and not about the request. + if _, err := svc.Mint(MintRequest{Subject: "user-1"}); err != nil { + t.Fatalf("plain mint refused: %v", err) + } +} + +func TestMint_EmailIsCarriedAndNormalisedWhenAllowed(t *testing.T) { + cfg := mintTestConfig() + cfg.AllowEmail = true + svc := newMintService(t, cfg) + + res, err := svc.Mint(MintRequest{Subject: "user-1", Email: " Alice@Example.COM "}) + if err != nil { + t.Fatalf("Mint: %v", err) + } + if res.Email != "alice@example.com" { + t.Fatalf("result email = %q, want the trimmed lower-cased address", res.Email) + } + claims, err := parseMinted(t, res.Token, cfg.Audience) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.Email != "alice@example.com" { + t.Fatalf("claim email = %q", claims.Email) + } +} + +// omitempty has to actually hold: a relying party that reads the claim's +// presence as "vault42 asserted an address" must not see one on a token whose +// caller never sent it. +func TestMint_NoEmailClaimWhenNoneWasAsked(t *testing.T) { + cfg := mintTestConfig() + cfg.AllowEmail = true + svc := newMintService(t, cfg) + + res, err := svc.Mint(MintRequest{Subject: "user-1"}) + if err != nil { + t.Fatalf("Mint: %v", err) + } + if res.Email != "" { + t.Fatalf("result email = %q, want empty", res.Email) + } + claims, err := parseMinted(t, res.Token, cfg.Audience) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.Email != "" { + t.Fatalf("claim email = %q, want absent", claims.Email) + } +} + +func TestMint_RefusesAnAddressTheValidatorRejects(t *testing.T) { + cfg := mintTestConfig() + cfg.AllowEmail = true + svc := newMintService(t, cfg) + + // Each of these is rejected by sanitize.Email for a different reason: no + // domain, a display-name wrapper (the caller would store a string that is + // not the address), a trailing comment, the erasure tombstone domain, and a + // local part past the length limit. + for _, bad := range []string{ + "not-an-address", + "Alice ", + "alice@example.com (alice)", + "alice@deleted.invalid", + strings.Repeat("a", 250) + "@example.com", + } { + if _, err := svc.Mint(MintRequest{Subject: "user-1", Email: bad}); !errors.Is(err, ErrMintEmailInvalid) { + t.Errorf("Mint(email=%q) error = %v, want ErrMintEmailInvalid", bad, err) + } + } +} + +// ValidateMintEmail is exported so the fuzzer reaches the validator rather than +// only the handler wrapping it, the same reason ValidateMintSubject is. +func TestValidateMintEmail(t *testing.T) { + got, err := ValidateMintEmail("\t Bob@Example.Com \n") + if err != nil { + t.Fatalf("ValidateMintEmail: %v", err) + } + if got != "bob@example.com" { + t.Fatalf("got %q", got) + } + if _, err := ValidateMintEmail("@"); !errors.Is(err, ErrMintEmailInvalid) { + t.Fatalf("err = %v, want ErrMintEmailInvalid", err) + } +} diff --git a/scripts/cov-gaps.py b/scripts/cov-gaps.py index 9c647d24..b9a6c6c6 100755 --- a/scripts/cov-gaps.py +++ b/scripts/cov-gaps.py @@ -244,7 +244,13 @@ # the one-line helper that folds case before the admin-tier role denylist is # read. It replaced six direct map lookups, none of which were statements of # their own, so the whole increase is the new function body. -BASELINE_TOTAL_STATEMENTS = 12952 +# +# +18 again (12952 -> 12970) for the minted email claim. internal/service 1794 +# -> 1810 is checkEmail and ValidateMintEmail, the opt-in gate and the +# normalise-then-validate helper; internal/handler 1919 -> 1921 is the two new +# arms of mintErrorCode. The two changes are disjoint by package, which is why +# the totals add rather than one superseding the other. +BASELINE_TOTAL_STATEMENTS = 12970 # BASELINE_MAX_ENTRIES is a ratchet: the exclusion set may only shrink, so a new # entry has to be paid for by covering a statement somewhere else or by an @@ -317,7 +323,7 @@ # existed when 50 was measured, so it is a like-for-like move rather than a # loosened bar, and the pair is named in the set with the argument that fstat # fails only with EBADF or EFAULT and this call site can produce neither. -# Lowered from 52 to 51 by covering internal/crypto/jwt.go:131, the modulus +# Lowered from 52 to 51 by covering internal/crypto/jwt.go:147, the modulus # fallback in KIDFromPublicKey. Its exclusion argued that reaching it "would need # the standard library to stop understanding RSA public keys", and that is not # so: x509.MarshalPKIXPublicKey understands *rsa.PublicKey perfectly well and @@ -376,7 +382,7 @@ "internal/email": 684, "internal/firstboot": 64, "internal/frontend": 15, - "internal/handler": 1919, + "internal/handler": 1921, "internal/honeypot": 208, "internal/httputil": 33, "internal/ipintel": 172, @@ -395,7 +401,7 @@ "internal/sanitize": 53, "internal/seed": 153, "internal/server": 260, - "internal/service": 1794, + "internal/service": 1810, "internal/useragent": 41, } diff --git a/tests/compliance/gate_liveness_test.go b/tests/compliance/gate_liveness_test.go index fa5e93fe..0038b675 100644 --- a/tests/compliance/gate_liveness_test.go +++ b/tests/compliance/gate_liveness_test.go @@ -1228,6 +1228,17 @@ var rawSourceScanByDesign = map[string]string{ "exactly what this gate has to read, because the defect it holds was four " + "documents describing a shipped control as absent. There is nowhere for the " + "phrase to hide either: a Markdown comment is still text an auditor reads.", + "tests/spec/mint_response_doc_parity_test.go:mintDocSection": "reads docs/api.md, " + + "which is Markdown. commentFreeSource cannot be used on it and says so: " + + "commentSyntaxFor knows .go, .sql, .yaml, .tpl and .txt, and t.Fatals on " + + "anything else rather than guessing -- so the alternative this check offers " + + "does not exist for this file. What the function extracts is the published " + + "POST /mint section, and published prose is exactly what it has to read: the " + + "defect it holds was api.md describing a response object while omitting a " + + "field the server sends, and spec.md makes api.md authoritative for response " + + "bodies. There is nowhere for a match to hide, either. The section is bounded " + + "by Markdown headings and the strings it looks for are a fenced json block " + + "and a table header, none of which is comment syntax in any language.", "tests/spec/badge_language_parity_test.go:goDirectRequires": "reads go.mod, " + "not Go source, and go.mod is the one file here where comment text carries " + "meaning: `// indirect` is how the format marks a requirement the module " + diff --git a/tests/spec/mint_response_doc_parity_test.go b/tests/spec/mint_response_doc_parity_test.go new file mode 100644 index 00000000..41948a28 --- /dev/null +++ b/tests/spec/mint_response_doc_parity_test.go @@ -0,0 +1,154 @@ +// Mint response documentation parity gate. +// +// docs/spec.md makes docs/api.md authoritative for response bodies, so api.md is +// not a description of the mint response -- it is the description. A field that +// MintResponse marshals and api.md omits is an undocumented field on the one +// endpoint whose whole job is to hand an assertion to somebody else's platform. +// +// That is not hypothetical. The email claim shipped in MintResponse with the +// request table updated and the response example and response field table both +// left alone, so the authoritative description of the object was missing a field +// the server was already sending. Nothing could see it: every test that touches +// a mint response body asserts on the fields it names, and a field nobody names +// is a field nobody checks. +// +// This gate reads the struct tags out of the source rather than marshaling a +// response, because omitempty means a runtime body proves only which fields were +// populated by that one request. The question is which fields the type is +// capable of emitting at all. +// +// The tests are read-only. They never write to the source tree. +package spec_test + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" +) + +var ( + mintResponseSource = filepath.Join("internal", "handler", "mint.go") + mintResponseDoc = filepath.Join("docs", "api.md") +) + +// mintResponseJSONNames returns every json name MintResponse can marshal, in +// declaration order, with omitempty and other options stripped. +func mintResponseJSONNames(t *testing.T) []string { + t.Helper() + path := filepath.Join(repoRoot(t), mintResponseSource) + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("parsing %s: %v", mintResponseSource, err) + } + + var names []string + ast.Inspect(file, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok || ts.Name == nil || ts.Name.Name != "MintResponse" { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok || st.Fields == nil { + return false + } + for _, f := range st.Fields.List { + if f.Tag == nil { + continue + } + raw, err := strconv.Unquote(f.Tag.Value) + if err != nil { + t.Fatalf("unquoting a struct tag on MintResponse: %v", err) + } + tag := reflect.StructTag(raw).Get("json") + name, _, _ := strings.Cut(tag, ",") + if name == "" || name == "-" { + continue + } + names = append(names, name) + } + return false + }) + + // A gate that finds nothing passes for the wrong reason. If MintResponse is + // renamed or moved, this has to fail rather than quietly stop looking. + if len(names) == 0 { + t.Fatalf("no json-tagged fields found on MintResponse in %s. If the type moved "+ + "or was renamed, move this gate with it: what it holds is that api.md "+ + "describes every field the mint response can carry.", mintResponseSource) + } + return names +} + +// mintDocSection returns the POST /mint section of api.md, from its heading to +// the next heading at the same level or above. +func mintDocSection(t *testing.T) string { + t.Helper() + doc := readFileString(t, filepath.Join(repoRoot(t), mintResponseDoc)) + + const heading = "#### POST /mint" + start := strings.Index(doc, heading) + if start < 0 { + t.Fatalf("no %q heading in %s; if the section was renamed, rename it here too", + heading, mintResponseDoc) + } + rest := doc[start+len(heading):] + + end := len(rest) + for _, next := range []string{"\n#### ", "\n### ", "\n## ", "\n# "} { + if i := strings.Index(rest, next); i >= 0 && i < end { + end = i + } + } + return rest[:end] +} + +// TestTheMintResponseDocumentsEveryFieldItCanSend fails when MintResponse gains +// a json field that the POST /mint success example or its field table does not +// mention. +func TestTheMintResponseDocumentsEveryFieldItCanSend(t *testing.T) { + names := mintResponseJSONNames(t) + section := mintDocSection(t) + + // The success example and the field table are two independent renderings of + // the same object, and the email field went missing from both. Checking them + // separately means fixing one does not silence the other. + exampleStart := strings.Index(section, "**Success response (200 OK):**") + if exampleStart < 0 { + t.Fatal("no success-response block in the POST /mint section of api.md") + } + example := section[exampleStart:] + if fence := strings.Index(example, "```json"); fence >= 0 { + example = example[fence:] + if end := strings.Index(example[len("```json"):], "```"); end >= 0 { + example = example[:len("```json")+end] + } + } + + tableStart := strings.Index(section, "| Field | Type | Description |") + if tableStart < 0 { + t.Fatal("no response field table in the POST /mint section of api.md") + } + table := section[tableStart:] + if end := strings.Index(table, "\n\n"); end >= 0 { + table = table[:end] + } + + for _, name := range names { + if !strings.Contains(example, `"`+name+`"`) { + t.Errorf("MintResponse marshals %q but the POST /mint success example does not "+ + "show it. api.md is authoritative for response bodies, so an absent field "+ + "is an undocumented one.", name) + } + if !strings.Contains(table, "`"+name+"`") { + t.Errorf("MintResponse marshals %q but the POST /mint response field table has "+ + "no row for it", name) + } + } +} diff --git a/web/src/__tests__/errorMessages.test.ts b/web/src/__tests__/errorMessages.test.ts index fd381a17..c40b1b30 100644 --- a/web/src/__tests__/errorMessages.test.ts +++ b/web/src/__tests__/errorMessages.test.ts @@ -113,6 +113,7 @@ const MAPPED_CODES = [ 'insufficient_scope', 'scope_not_permitted', 'role_not_permitted', + 'email_not_permitted', 'client_credentials_required', 'shared_visibility_disabled', 'rate_limited', diff --git a/web/src/errorMessages.ts b/web/src/errorMessages.ts index 91c2c5e2..c5d97a56 100644 --- a/web/src/errorMessages.ts +++ b/web/src/errorMessages.ts @@ -107,6 +107,7 @@ const errorKeys: Record = { insufficient_scope: 'error.forbidden', scope_not_permitted: 'error.forbidden', role_not_permitted: 'error.forbidden', + email_not_permitted: 'error.forbidden', client_credentials_required: 'error.forbidden', shared_visibility_disabled: 'error.forbidden', rate_limited: 'error.rate_limited',