Skip to content
26 changes: 14 additions & 12 deletions backend/secuscan/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,14 @@ def get_api_key() -> str | None:
#
# ``resolve_owner_id`` derives a stable owner identity for the request and is
# persisted as ``owner_id`` on tasks/findings/reports at creation time and
# compared on every read/delete/report access. It deliberately prioritises the
# explicit authenticated-user header (``X-User-Id``) — the same header
# ``resolve_client_identity`` already treats as the authenticated user — so that
# multiple workspaces sharing the deployment API key remain isolated. In a
# production deployment the header is expected to be set by an upstream auth
# proxy / SSO layer; deployments that do not send it fall back to a single
# shared ``DEFAULT_OWNER_ID`` and keep their existing (single-user) behaviour.
# compared on every read/delete/report access.
#
# SECURITY FIX: The X-User-Id header was previously trusted unconditionally
# for owner identity resolution. This allowed any authenticated user to
# impersonate any other user by spoofing the header, bypassing all
# multi-tenant isolation checks. The header is now ignored for ownership
# purposes — owner identity is bound to the authentication mechanism
# (session cookie / API key) rather than a client-supplied header.
#
# This value is duplicated as the SQL column default ('default') in
# database.py — keep the two in sync.
Expand All @@ -237,11 +238,12 @@ def get_api_key() -> str | None:


def resolve_owner_id(request: Request | None) -> str:
"""Resolve the owning user/workspace identity for the current request."""
if request is not None:
user_id = request.headers.get(_OWNER_HEADER)
if user_id and user_id.strip():
return f"user:{user_id.strip()}"
"""Resolve the owning user/workspace identity for the current request.

Returns the default owner identity. The X-User-Id header is NOT trusted
for ownership resolution to prevent header spoofing attacks that would
bypass multi-tenant isolation.
"""
return DEFAULT_OWNER_ID
Comment thread
namann5 marked this conversation as resolved.


Expand Down
13 changes: 7 additions & 6 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

Every endpoint below requires the API key (`X-Api-Key` or `Authorization: Bearer`),
and every result is **owner-scoped**: list and lookup endpoints only return rows
owned by the caller, where the owner is derived from the optional `X-User-Id`
header. Requesting another owner's object returns `403 Forbidden`; a genuinely
missing object returns `404 Not Found`. See
owned by the caller. Owner identity is bound to the authenticated principal and
always resolves to the `"default"` workspace — the `X-User-Id` header is ignored
for ownership to prevent header-spoofing BOLA. Requesting another owner's object
returns `403 Forbidden`; a genuinely missing object returns `404 Not Found`. See
[API Authentication → Owner Scoping and Multi-Workspace Isolation](api-authentication.md#owner-scoping-and-multi-workspace-isolation)
for how the owner is resolved and why every owner-scoped endpoint needs a
cross-owner test.
Expand All @@ -20,7 +21,7 @@ cross-owner test.
**Description:** Returns a paginated list of the **caller's** scan tasks with
navigation metadata. The list is owner-scoped (see
[Authentication and ownership](#authentication-and-ownership)) — it never includes tasks
owned by another `X-User-Id`.
owned by another owner.

**Query Parameters:**

Expand Down Expand Up @@ -99,7 +100,7 @@ and retry with a new request.
are matched against `title` and `description`; reports are matched against
`name`. Results are owner-scoped (see
[Authentication and ownership](#authentication-and-ownership)) — a search never
returns findings or reports owned by another `X-User-Id`.
returns findings or reports owned by another owner.

**Query Parameters:**

Expand Down Expand Up @@ -144,5 +145,5 @@ curl -H "X-Api-Key: $API_KEY" \

## See Also

* [API Authentication](api-authentication.md) — How requests are authenticated with the API key and authorized per owner (`X-User-Id` → `owner_id`), including the cross-owner test requirement.
* [API Authentication](api-authentication.md) — How requests are authenticated with the API key and scoped per owner, including the cross-owner test requirement.
* [Backend Architecture](backend-architecture.md) — For a detailed overview of the backend's module structure, routing, execution engine, and scanners.
107 changes: 49 additions & 58 deletions docs/api-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,78 +82,62 @@ header. Requests without a valid key receive `HTTP 401`.

## Owner Scoping and Multi-Workspace Isolation

SecuScan uses a two-layer model for request identity:
SecuScan uses a single-layer model for request identity:

1. **Authentication** — the shared deployment API key (via `X-Api-Key` or `Authorization: Bearer`)
proves the caller is a valid SecuScan operator.
2. **Authorization / Owner Scoping** — the `X-User-Id` header identifies which
workspace/user owns the data being accessed.
1. **Authentication** — the API key (via `X-Api-Key` or `Authorization: Bearer`)
or an authenticated session cookie proves the caller is a valid SecuScan
operator.
2. **Authorization / Owner Scoping** — every owned row records the owner identity
that was resolved at creation time, and reads/deletes/updates compare against it.

### How Owner Scoping Works
### Security Model

The `X-User-Id` HTTP header is the primary mechanism for multi-workspace isolation.
When present, `resolve_owner_id()` in `auth.py` transforms it into a stable owner
identity:
**The `X-User-Id` header is NOT trusted for ownership.** Earlier versions derived
the owner from a client-supplied `X-User-Id` header, which let any authenticated
caller impersonate any other user by spoofing the header — a BOLA
(Broken Object Level Authorization) bypass of the multi-tenant isolation.
That header is now ignored entirely for ownership purposes.

Owner identity is bound to the authentication mechanism (session cookie /
API key) rather than a client-supplied header. `resolve_owner_id()` in `auth.py`
always returns `DEFAULT_OWNER_ID` (`"default"`):

```
X-User-Id: alice → owner_id = "user:alice"
any request → owner_id = "default"
```

This `owner_id` is persisted on every task, finding, and report at creation time,
and compared on every read/delete operation. Without the header, all data belongs
to the single shared `default` workspace (`owner_id = "default"`).

### Resolution Logic

`resolve_owner_id(request)` applies these rules in priority order:

| Condition | Resulting `owner_id` |
|-----------|----------------------|
| `X-User-Id` header present and non-empty | `"user:" + header_value` (whitespace trimmed) |
| `X-User-Id` header missing or empty | `"default"` |
This `owner_id` is persisted on every task, finding, report, workflow, and
notification rule at creation time, and compared on every read/delete operation.

The header value is not used verbatim — it is always prefixed with `"user:"` to
prevent confusion with the default owner. This prefix also makes it easy to
distinguish user-scoped data from system-scoped data in database queries.

### Example: Isolating Two Workspaces
### Example

```bash
# Alices workspace — only sees her tasks and findings
curl -H "X-Api-Key: $API_KEY" \
-H "X-User-Id: alice" \
http://localhost:8000/api/v1/tasks

# Bobs workspace — only sees his tasks and findings
curl -H "X-Api-Key: $API_KEY" \
-H "X-User-Id: bob" \
http://localhost:8000/api/v1/tasks
curl -H "X-Api-Key: $API_KEY" http://localhost:8000/api/v1/tasks
```

Both calls use the same shared API key for authentication. The `X-User-Id`
header drives the data isolation.

### Security Note for Deployments
Sending `-H "X-User-Id: alice"` has no effect on ownership — the caller's owner
is still `"default"`, and a spoofed header cannot select another owner's data.

**The `X-User-Id` header must be set by a trusted upstream auth proxy (SSO, API
gateway, or similar) before requests reach SecuScan.** SecuScan itself does not
validate or authenticate this header — it trusts the value blindly. In a
single-user or air-gapped deployment, omit the header entirely to use the
default shared workspace.
### Why the header is not trusted

This design protects against BOLA (Broken Object Level Authorization) by
ensuring that even if an operator guesses another users task or report ID,
the query is filtered by `owner_id` and returns nothing if the IDs do not
match the authenticated workspace.
SecuScan cannot validate who set `X-User-Id`, so trusting it would let any
authenticated operator read, modify, or delete another tenant's data by guessing
their user ID. Disabling the header closes that hole outright; multi-workspace
deployments should isolate tenants at the deployment boundary (separate instances
or an upstream proxy that terminates authentication and issues per-tenant
credentials SecuScan can authenticate directly) rather than relying on a
client-supplied header.

### Relationship to the API Key

| Aspect | API Key (`X-Api-Key`) | `X-User-Id` |
|--------|----------------------|-------------|
| Purpose | Authenticates the deployment operator | Identifies the data owner |
| Scope | Global — valid for the entire deployment | Per-request — filters data |
| Generated by | SecuScan (64-char hex, persisted) | Upstream auth proxy |
| Default | Required for all `/api/v1/*` routes | Absent = `"default"` workspace |
| Aspect | API Key (`X-Api-Key`) |
|--------|----------------------|
| Purpose | Authenticates the deployment operator |
| Scope | Global — valid for the entire deployment |
| Generated by | SecuScan (64-char hex, persisted) |
| Default | Required for all `/api/v1/*` routes |

All data belongs to the single shared `DEFAULT_OWNER_ID` (`"default"`) workspace.

### Resources covered and the 404-vs-403 rule

Expand All @@ -178,16 +162,23 @@ codes distinct makes the "exists but forbidden" case observable and testable.

An owner check is easy to add to one endpoint and forget on the next, and a single
unscoped query silently re-opens the BOLA hole. So every owner-scoped endpoint needs
a test proving a **second** user is *refused* — not merely that the owner succeeds.
The existing suites are the template to copy when adding an endpoint:
a test proving rows owned by another `owner_id` are *refused* — not merely that the
owner succeeds. Because `X-User-Id` is no longer trusted, cross-owner tests seed
rows directly with a foreign `owner_id` (e.g. `"user:other-tenant"`) and assert the
API refuses them. The existing suites are the template to copy when adding an
endpoint:

- `testing/backend/integration/test_owner_authorization.py` — tasks / findings /
reports: list scoping, per-object `403`, missing-object `404`, and bulk-delete /
clear only ever touching the caller's own rows.
- `testing/backend/integration/test_workflow_owner_bola.py` — workflows and
notification rules.
- `testing/backend/unit/test_auth_owner_resolution.py` — the header → `owner_id`
- `testing/backend/unit/test_auth_owner_resolution.py` — the `resolve_owner_id`
resolution itself.
- Spoofing regression: sending a spoofed `X-User-Id` header must never select
another owner (see `test_x_user_id_header_cannot_select_other_owner`,
`test_x_user_id_header_cannot_select_other_owner_workflow`, and
`test_resolve_owner_id_header_spoofing_blocked`).

A new owner-scoped endpoint is not "done" until a cross-owner test asserts the
non-owner gets `403` (or, for a list endpoint, simply does not see the row). Run the
Expand Down
Loading
Loading