| tags |
|
|||
|---|---|---|---|---|
| aliases |
|
|||
| status | current |
Every LLC API endpoint that reads or mutates company-scoped data must know
which company (tenant) the request is for. This document is the single
source of truth for how that tenant context is derived and enforced —
previously this was undocumented and callers only discovered the contract via
opaque 400 responses (#12215).
Human-facing LLC routes (i.e. everything under /api/llc/* except the
agent-bearer surface described below) resolve tenant context through a single
FastAPI dependency chain, defined in
api/user_management/dependencies.py:
get_current_user → get_tenant_context → require_org_context
get_current_user— resolves the authenticated user from the session JWT (Authorization: Bearer ...). Always required first; there is no tenant context without an authenticated user.get_tenant_context— builds aTenantContext(org_id, user_id, is_platform_admin)from the request, using this precedence fororg_id(first match wins), per GH#10750 A5:X-Organization-Idrequest header.company_id(orid) path parameter — e.g./companies/{company_id}/....company_idquery parameter (?company_id=...).org_idJWT claim (legacy fallback, no extra DB check).
require_org_context— the dependency routers actually declare; wrapsget_tenant_contextand raises400with a message naming theX-Organization-Idheader if noorg_idcould be resolved from any source.
A client cannot grant itself access to another company's data merely by
sending an X-Organization-Id header or company_id param:
- If the resolved
org_idcame from the header/path/query (sources 1–3) and the caller is not a platform admin,get_tenant_contextperforms a membership check againstllc_company_memberships. Non-members are rejected with403before any handler code runs. - Individual routers additionally re-validate that the specific resource
being read/written belongs to
ctx.org_id(see below) — the header only ever selects which of the caller's own companies is in scope; it can never grant access to a company the caller isn't a member of. - Platform admins (
role == "admin"oris_platform_admin == Trueon the JWT) may address any company without a membership check — this is the only privileged path.
Once ctx.org_id is resolved, routers must additionally confirm the
specific row(s) being accessed belong to that org. The canonical helpers
(llc/deps.py, GH#10148/#12184) are:
| Helper | Use when |
|---|---|
assert_company_access(ctx, company_id) |
The route already loaded/knows a company_id (e.g. from a path param or a fetched row) and just needs the tenant check. |
load_authorized(session, Model, obj_id, ctx, ...) |
Loading a single row by id — fetch + tenant-check in one call. |
load_owned_project(project_id, session, ctx) |
Project-scoped resources specifically (sprints, findings). |
All of these return 404 (not 403) on a cross-tenant mismatch, so a
cross-tenant caller can't distinguish "not yours" from "doesn't exist".
_extract_request_org_id only recognizes path params literally named
company_id or id. Routes nested under /companies/{company_id}/...
therefore resolve org_id from the URL with no header required. Flat
routes keyed by a different resource id (e.g. /goals/{goal_id},
/agents/{agent_id}/heartbeat/trigger) have no such path param, so the
caller must send X-Organization-Id (or rely on the JWT org_id
fallback) — otherwise require_org_context returns 400.
This is expected, not a bug: when calling any flat-resource LLC endpoint,
always send X-Organization-Id. It is safe to send it unconditionally on
every LLC request (nested routes simply ignore it in favour of the path
value, per the precedence above).
A few LLC routers intentionally use a different mechanism because the caller is not a human session:
- Agent bearer auth (
/api/llc/agent/*) —LLCAgentAuthMiddleware(llc/middleware/agent_auth.py) validates anAuthorization: Bearer <agent-api-key>token againstllc_agent_api_keysand injectsrequest.state.agent_id/request.state.company_iddirectly. Agents authenticate with a per-agent key scoped to exactly one company at creation time, so there is no header to spoof and noTenantContextto resolve. - Board-role checks (
llc/api/controls.py, replay.py) — instant-control endpoints (pause/resume/terminate) require a specificMembershipRole(OWNER/ADMIN), not just membership, viallc.deps.require_board_role. This is a stricter, not looser, variant of the same DB-backed membership check. - Webhooks (
llc/api/github_webhooks.py) — authenticated via GitHub's HMAC webhook signature, not a user session; noTenantContextexists to resolve. - Public/global endpoints (
llc/api/health.py,llc/api/adapters.py) — return no tenant-scoped data (a health probe; the list of registered adapter types, not per-company config), so no tenant context is needed.
- #12215: documented this contract; standardized error messaging;
closed two IDOR gaps found during the audit —
llc/api/activity.pyandllc/api/routines.pypreviously required only authentication with no tenant check at all, letting any authenticated user read another company's activity log, or read/update/delete/trigger another company's routines by UUID. Both now userequire_org_context+assert_company_access/an owned-row loader, consistent with every other LLC router. - #12184/#10148: introduced the shared
assert_company_access/load_authorized/load_owned_projecthelpers, consolidating ~9 near-identical per-router IDOR checks. - #12148: templates.py hardened to bind company-scoped operations to
ctx.org_idrather than trusting a caller-suppliedcompany_id. - #12233:
companies.pyGET/PATCH/DELETE /{company_id}gained the samectx.org_id == company_idguard as other routers. - #10750 A5: introduced the
TenantContextprecedence + membership-check design described above.