PSA audit: auth + tenancy security fixes #48

Merged
vas2000-work merged 10 commits from feat/m1-vas into main 2026-05-29 22:05:32 +02:00
Owner

Remediates verified-open PSA-audit findings in the legacy PSA auth/tenancy layer (re-verified against origin/main).

  • reset_password and the welcome/setup flow now bind the token to its user ({user_id}.{secret}), closing the cross-user account-takeover where the newest unused token across ALL users was accepted.
  • login / login_with_google / refresh_token reject suspended/cancelled tenants (ensure_tenant_active), so a tenant suspension takes effect immediately instead of lingering until token expiry.
  • create/update for tickets, appointments, and companies validate every foreign id (company/contact/site/assigned_to/team/contract/sla/asset/etc.) against the caller's tenant, closing cross-tenant write linkage.
  • Google OAuth hardened: auto-link only when the existing local email is verified; super_admin via an exact-email allowlist (OAUTH_SUPER_ADMIN_EMAILS) replacing the domain auto-promote; fail-closed provisioning rather than dropping new identities into the default tenant.
  • RLS GUC setter parameterized via set_config and the ineffective non-transaction with_tenant removed; the fail-open policy is unchanged so existing explicit tenant_id queries keep working.
  • CI publishes the image under a single mokosh-server name.
  • Unit tests for the token-parse and email-allowlist helpers.

Deferred follow-ups: staged RLS per-query enforcement (needs a live DB to verify); routing Google through the SSO federation path (legacy HS256 kept for now); a DB-backed integration test harness.

Remediates verified-open PSA-audit findings in the legacy PSA auth/tenancy layer (re-verified against origin/main). - reset_password and the welcome/setup flow now bind the token to its user (`{user_id}.{secret}`), closing the cross-user account-takeover where the newest unused token across ALL users was accepted. - login / login_with_google / refresh_token reject suspended/cancelled tenants (ensure_tenant_active), so a tenant suspension takes effect immediately instead of lingering until token expiry. - create/update for tickets, appointments, and companies validate every foreign id (company/contact/site/assigned_to/team/contract/sla/asset/etc.) against the caller's tenant, closing cross-tenant write linkage. - Google OAuth hardened: auto-link only when the existing local email is verified; super_admin via an exact-email allowlist (OAUTH_SUPER_ADMIN_EMAILS) replacing the domain auto-promote; fail-closed provisioning rather than dropping new identities into the default tenant. - RLS GUC setter parameterized via set_config and the ineffective non-transaction with_tenant removed; the fail-open policy is unchanged so existing explicit tenant_id queries keep working. - CI publishes the image under a single mokosh-server name. - Unit tests for the token-parse and email-allowlist helpers. Deferred follow-ups: staged RLS per-query enforcement (needs a live DB to verify); routing Google through the SSO federation path (legacy HS256 kept for now); a DB-backed integration test harness.
Two PSA-audit P0 fixes in src/modules/auth/service.rs:

reset_password previously selected the most-recent unused password_reset_tokens row across ALL users (ORDER BY created_at DESC LIMIT 1) and verified the presented token against it - a leaked or guessed token could reset another account, and concurrent resets locked out legitimate users. Tokens are salted Argon2 hashes so they can't be looked up by value; the emailed token is now `{user_id}.{secret}` and reset_password scopes the candidate lookup to that user before verifying the secret. Both mint sites (request_password_reset and the create_user welcome/setup flow) emit the user-bound shape.

login, login_with_google, and refresh_token checked the user's status but never the owning tenant's. Added ensure_tenant_active(), called on every session-minting path, so a suspended/cancelled tenant is rejected immediately instead of lingering until token expiry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Service create/update methods inserted foreign-key ids from the request body without checking they belong to the caller's tenant, letting a tenant link rows to another tenant's data (PSA-audit P0). Add a private tenant-scoped validate_fk / validate_fk_opt helper to TicketService, CalendarService, and ContactService and call it before each INSERT/UPDATE.

Validated: ticket company/contact/site/assigned_to/team/contract/sla/asset (create + the subset present on update); appointment assigned_to/company/contact/site/ticket/project/task; company parent_company/account_manager/sla. The table name is a compile-time constant (never user input). create_contact already validated its company via get_company and is left unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Full PSA-audit Google OAuth hardening:
- Auto-link a Google identity to an existing local account only when that account's own email is verified (email_verified_at set), closing account-takeover-by-email where a password account was registered under someone else's address.
- Replace the email-DOMAIN super_admin auto-promote with an exact-email allowlist: oauth_super_admin_domains -> oauth_super_admin_emails (env OAUTH_SUPER_ADMIN_DOMAINS -> OAUTH_SUPER_ADMIN_EMAILS), defaulting empty.
- Fail-closed provisioning: a brand-new Google identity is auto-created only when its email is in the allowlist (as super_admin, bootstrap path); every other unrecognized identity is rejected instead of being dropped into the hardcoded default tenant. Real users must be invited.

Threaded through AppConfig (main.rs), create_api_router (router.rs), and AuthService. The matching c-01 OAUTH_SUPER_ADMIN_EMAILS value is set in the docker repo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The RLS tenant GUC (app.current_tenant) was set via a format!-interpolated SQL string (injection-shaped, though tenant_id is a Uuid), and the non-transaction with_tenant helper ran SET LOCAL on a pooled connection - a no-op outside a transaction, and a cross-request tenant-context leak if it ever took effect. Parameterize begin_with_tenant via set_config('app.current_tenant', $1, true) and remove with_tenant plus the now-orphaned TenantConnection. The transaction-scoped begin_with_tenant remains as the building block for staged RLS enforcement; the policy stays fail-open so existing explicit `WHERE tenant_id` queries are unaffected.

Follow-up (staged RLS, second half): route tickets/contacts read paths through begin_with_tenant for real row-level isolation, verify with a psql smoke, then flip the tenant_isolation policy fail-closed (the migration/bootstrap role needs BYPASSRLS). The cross-tenant FK validation already landed closes the write-side leak in the meantime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
c-01's renamed mokosh-server stack pulls .../psa-systems-private/mokosh-server:latest, but hc-01/nc-01 still reference mokosh-api. Build once and push both image names so neither host breaks during the rename; drop the mokosh-api alias once every host references mokosh-server.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per the decision to rename across all hosts (drop the dual-publish): CI now publishes only dev.a8n.run/psa-systems-private/mokosh-server. hc-01/nc-01 image references are updated to mokosh-server in the docker repo so no host pulls the retired mokosh-api name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
test(auth): unit-test reset-token parsing + super-admin email allowlist
Some checks failed
Check / fmt + clippy + compile + tests (pull_request) Failing after 4s
92c485e257
Extract the user-bound token split ({user_id}.{secret}) and the case-insensitive email-allowlist match into pure helpers (parse_user_bound_token, is_allowlisted_email) used by reset_password and provision_user_from_google, and cover them with unit tests: valid/invalid token shapes, dotted secrets, empty-secret rejection, and case-insensitive / empty-allowlist matching. Tenant-status and DB-backed paths still need an integration harness (deferred).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Apply rustfmt to the audit edits (auth/calendar/contacts/tickets service.rs); the CI check.yml runs `cargo fmt --all --check` and the hand/agent edits were not fmt-clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(router): use api.msp host prefix in not_a_frontend fallback
Some checks failed
Check / fmt + clippy + compile + tests (pull_request) Failing after 12s
1b2568e65e
The fallback page derived the bunyip hub link via strip_prefix("msp-api."), which fails on the api.msp.a8n.systems host and silently falls back to the hardcoded apex. Match api.msp. (the current topology) and update the stale msp-api.<tld> comments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(test): bind reset-token strings before borrowing (E0716)
All checks were successful
Check / fmt + clippy + compile + tests (pull_request) Successful in 31s
Create release / Create release from merged PR (pull_request) Has been skipped
dc202a9a9b
parse_user_bound_token returns Option<(Uuid, &str)> borrowing from its &str input. The unit tests at lines 1309 and 1329 passed &format!(...) directly, producing a temporary that died before the assert_eq! consumed the borrow. clippy --all-targets surfaced E0716 (the lib-only check missed it because the failure is in #[cfg(test)] code). Bind format!(...) to `let token = ...` first, mirroring the existing rejects_bad_shapes pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
vas2000-work deleted branch feat/m1-vas 2026-05-29 22:05:32 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!48
No description provided.