Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.d/2.12.7-keyverse-account-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Security

- Bind production Keyverse account-derived organization, workspace, and role
claims to one provisioned local authorization scope before ABAC or RBAC.
112 changes: 95 additions & 17 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ class CurrentAccount:
display_name: str
preferred_locale: str | None
corporate_entity_ids: frozenset[str]
process_unit_ids: frozenset[str]
permission_codes: frozenset[str]

def has_permission(self, permission_code: str) -> bool:
Expand All @@ -130,6 +131,7 @@ def _decode_access_token(token: str, settings: Settings) -> dict:
issuer=settings.oidc_issuer,
audience=settings.oidc_audience,
leeway=settings.oidc_clock_skew_seconds,
options={"require": ["exp", "iat", "sub"]},
)
except HTTPException:
raise
Expand All @@ -141,6 +143,33 @@ def _decode_access_token(token: str, settings: Settings) -> dict:
return claims


def _keyverse_account_claims(claims: dict) -> tuple[str, str, list[str]]:
"""Return Keyverse's atomic account scope, rejecting ambiguous wire shapes."""
organization = claims.get("org")
workspace = claims.get("workspace")
roles = claims.get("role")
valid_roles = (
isinstance(roles, list)
and bool(roles)
and all(isinstance(role, str) and role.strip() for role in roles)
and len({role.strip() for role in roles}) == len(roles)
)
if not (
isinstance(organization, str)
and organization.strip()
and organization == organization.strip()
and isinstance(workspace, str)
and workspace.strip()
and workspace == workspace.strip()
and valid_roles
):
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Keyverse token must contain one org, one workspace, and unique roles",
)
return organization, workspace, [role.strip() for role in roles]


async def get_current_account(
credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme),
pool: asyncpg.Pool = Depends(get_pool),
Expand All @@ -149,38 +178,87 @@ async def get_current_account(
settings = load_settings()
claims = _decode_access_token(credentials.credentials, settings)
subject = claims["sub"]
keyverse_scope = (
_keyverse_account_claims(claims)
if settings.keyverse_claim_binding_required
else None
)

async with pool.acquire() as conn:
account_row = await conn.fetchrow(
"select user_account_id, display_name, preferred_locale from user_account where external_subject_id = $1",
subject,
)
if keyverse_scope:
organization, workspace, token_roles = keyverse_scope
account_row = await conn.fetchrow(
"""
select account.user_account_id, account.display_name,
account.preferred_locale, affiliation.corporate_entity_id,
affiliation.process_unit_id
from user_account account
join account_affiliation affiliation
on affiliation.user_account_id = account.user_account_id
join corporate_entity entity
on entity.corporate_entity_id = affiliation.corporate_entity_id
join process_unit process
on process.process_unit_id = affiliation.process_unit_id
and process.corporate_entity_id = entity.corporate_entity_id
where account.external_subject_id = $1
and entity.corporate_entity_code = $2
and process.process_unit_code = $3
""",
subject,
organization,
workspace,
)
Comment on lines +190 to +210

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Keyverse resolution requires an affiliation row with a matching process unit

The Keyverse branch of get_current_account inner-joins account_affiliation to process_unit and requires both corporate_entity_code and process_unit_code to match the token's org/workspace. A provisioned account whose affiliation lacks a process_unit_id, or whose codes differ from the token, resolves to no row and gets 403. Confirm the schema and provisioning always populate a matching affiliation.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

else:
account_row = await conn.fetchrow(
"select user_account_id, display_name, preferred_locale from user_account where external_subject_id = $1",
subject,
)
if account_row is None:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"token is valid but no user_account is provisioned for this subject "
"(run scripts/seed_demo_data.py, or provision the account, first)",
)

entity_rows = await conn.fetch(
"select corporate_entity_id from account_affiliation where user_account_id = $1",
account_row["user_account_id"],
)
permission_rows = await conn.fetch(
"""
select distinct rp.permission_code
from account_role_assignment ara
join role_permission rp on rp.access_role_id = ara.access_role_id
where ara.user_account_id = $1
""",
account_row["user_account_id"],
)
if keyverse_scope:
entity_rows = [{"corporate_entity_id": account_row["corporate_entity_id"]}]
process_rows = [{"process_unit_id": account_row["process_unit_id"]}]
permission_rows = await conn.fetch(
"""
select distinct permission.permission_code
from account_role_assignment assignment
join access_role role
on role.access_role_id = assignment.access_role_id
join role_permission permission
on permission.access_role_id = assignment.access_role_id
where assignment.user_account_id = $1
and role.role_code = any($2::text[])
""",
account_row["user_account_id"],
token_roles,
)
Comment thread
seonghobae marked this conversation as resolved.
else:
entity_rows = await conn.fetch(
"select corporate_entity_id from account_affiliation where user_account_id = $1",
account_row["user_account_id"],
)
process_rows = []
permission_rows = await conn.fetch(
"""
select distinct rp.permission_code
from account_role_assignment ara
join role_permission rp on rp.access_role_id = ara.access_role_id
where ara.user_account_id = $1
""",
account_row["user_account_id"],
)

return CurrentAccount(
user_account_id=str(account_row["user_account_id"]),
external_subject_id=subject,
display_name=account_row["display_name"],
preferred_locale=account_row["preferred_locale"],
corporate_entity_ids=frozenset(str(row["corporate_entity_id"]) for row in entity_rows),
process_unit_ids=frozenset(str(row["process_unit_id"]) for row in process_rows),
permission_codes=frozenset(row["permission_code"] for row in permission_rows),
)
2 changes: 2 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class Settings:
oidc_discovery_uri: str
oidc_jwks_uri_override: str
oidc_clock_skew_seconds: int
keyverse_claim_binding_required: bool
# Exact browser origins allowed by CORS. Comma-separated FRONTEND_ORIGINS;
# never a wildcard -- the backend only serves the product UI.
frontend_origins: list[str]
Expand Down Expand Up @@ -122,6 +123,7 @@ def load_settings() -> Settings:
)
),
oidc_clock_skew_seconds=oidc_clock_skew_seconds,
keyverse_claim_binding_required=bool(keyverse_issuer),
Comment thread
seonghobae marked this conversation as resolved.
frontend_origins=[
origin.strip()
for origin in os.environ.get("FRONTEND_ORIGINS", "http://localhost:5173").split(",")
Expand Down
51 changes: 37 additions & 14 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,10 +414,16 @@ def _rankweave_client():


def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool:
"""ABAC: public rows are visible; private rows require same-corp affiliation."""
"""ABAC: public rows are visible; private rows require the bound local scope."""
if post["visibility_code"] == "public":
return True
return str(post["corporate_entity_id"]) in account.corporate_entity_ids
return (
str(post["corporate_entity_id"]) in account.corporate_entity_ids
and (
not account.process_unit_ids
or str(post.get("process_unit_id")) in account.process_unit_ids
)
)
Comment thread
seonghobae marked this conversation as resolved.
Comment on lines +420 to +426

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Private posts vanish for Keyverse users across several endpoints

When account.process_unit_ids is non-empty (every production Keyverse session), _can_see_post requires a private post's process_unit_id to be in that set, reading it via post.get("process_unit_id"). Several callers pass records that never select process_unit_id: load_visible_ranking_posts, fetch_upcoming_commitments, the report member and leftover-pair dicts in fetch_period_reports, and visible_mention_post_ids. For those, the lookup is always str(None), so every private post is hidden. Keyverse users get empty rankings, calendar commitments, period reports, and mention navigation.

Prompt for agents
The new process-unit check in _can_see_post (backend/app/main.py) reads post.get("process_unit_id") and, for Keyverse accounts (process_unit_ids non-empty), rejects any private post whose process_unit_id is absent from the record. Several record-producing queries were NOT updated to select process_unit_id, so their private rows evaluate to str(None) and are always dropped for Keyverse users. Update these to select and carry process_unit_id: load_visible_ranking_posts in backend/app/ranking_ingestion.py (the select post_id/visibility_code/corporate_entity_id from source_post); fetch_upcoming_commitments in backend/app/issue_ticket_ingestion.py (add p.process_unit_id and include it in the returned dict); fetch_period_reports, list_period_report_summaries, and fetch_period_comparison in backend/app/report_ingestion.py (add p.process_unit_id to the member and leftover-pair selects and include "process_unit_id" in the emitted member/leftover dicts); and visible_mention_post_ids, visible_affiliation_post_ids, visible_team_mention_post_ids in backend/app/knowledge_graph.py (add post.process_unit_id to each select). Verify all other _can_see_post callers similarly provide process_unit_id.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



def _is_synthetic_demo_member(member: dict[str, Any], demo_entity_ids: set[str]) -> bool:
Expand Down Expand Up @@ -540,7 +546,9 @@ async def _lookup_post_labels(conn: asyncpg.Connection, rows: list[asyncpg.Recor


async def _post_filter_options(
conn: asyncpg.Connection, corporate_entity_ids: frozenset[str]
conn: asyncpg.Connection,
corporate_entity_ids: frozenset[str],
process_unit_ids: frozenset[str],
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Return every authorized filter value, not only values on the current page."""
visibility_sql = f"""
Expand All @@ -552,7 +560,9 @@ async def _post_filter_options(
on lookup.lookup_category = 'post_visibility'
and lookup.lookup_code = post.visibility_code
where (post.visibility_code = 'public'
or post.corporate_entity_id::text = any($1::text[]))
or (post.corporate_entity_id::text = any($1::text[])
and (cardinality($2::text[]) = 0
or post.process_unit_id::text = any($2::text[]))))
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
order by display_order, code
"""
Expand All @@ -565,17 +575,19 @@ async def _post_filter_options(
on lookup.lookup_category = 'voc_type'
and lookup.lookup_code = post.voc_type_code
where (post.visibility_code = 'public'
or post.corporate_entity_id::text = any($1::text[]))
or (post.corporate_entity_id::text = any($1::text[])
and (cardinality($2::text[]) = 0
or post.process_unit_id::text = any($2::text[]))))
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
order by display_order, code
"""
# Safe SQL: both query strings are closed lookup statements; entity ids remain asyncpg parameters.
visibility_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
visibility_sql, list(corporate_entity_ids)
visibility_sql, list(corporate_entity_ids), list(process_unit_ids)
)
# Safe SQL: both query strings are closed lookup statements; entity ids remain asyncpg parameters.
type_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
type_sql, list(corporate_entity_ids)
type_sql, list(corporate_entity_ids), list(process_unit_ids)
)
return (
[{"code": row["code"], "label": row["label"]} for row in type_rows],
Expand Down Expand Up @@ -711,7 +723,10 @@ async def read_customer_master(
from source_post
where (nullif(btrim(source_customer_code), '') is not null
or nullif(btrim(source_customer_name), '') is not null)
and (visibility_code = 'public' or corporate_entity_id = any($1::uuid[]))
and (visibility_code = 'public' or (
corporate_entity_id = any($1::uuid[])
and (cardinality($2::uuid[]) = 0
or process_unit_id = any($2::uuid[]))))
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}
), ranked as (
select scoped.*,
Expand Down Expand Up @@ -757,6 +772,7 @@ async def read_customer_master(
order by top_groups.post_count desc, top_groups.customer_code, top_groups.customer_name
""",
list(account.corporate_entity_ids),
list(account.process_unit_ids),
)
# Safe SQL: the evidence query uses only closed schema fragments; authorized entity ids are bound.
source_author_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
Expand All @@ -777,7 +793,10 @@ async def read_customer_master(
join user_account author on author.user_account_id = post.author_account_id
where post.source_author_code is not null
and btrim(post.source_author_code) <> ''
and (post.visibility_code = 'public' or post.corporate_entity_id = any($1::uuid[]))
and (post.visibility_code = 'public' or (
post.corporate_entity_id = any($1::uuid[])
and (cardinality($2::uuid[]) = 0
or post.process_unit_id = any($2::uuid[]))))
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
), ranked as (
select scoped.*,
Expand Down Expand Up @@ -894,6 +913,7 @@ async def read_customer_master(
order by top_groups.post_count desc, top_groups.author_code
""",
list(account.corporate_entity_ids),
list(account.process_unit_ids),
)
entity_rows = await conn.fetch(
"""
Expand Down Expand Up @@ -1132,7 +1152,7 @@ async def list_posts(
search_term = search.strip() if search and search.strip() else None
async with pool.acquire() as conn:
voc_type_options, visibility_options = await _post_filter_options(
conn, account.corporate_entity_ids
conn, account.corporate_entity_ids, account.process_unit_ids
)
body_search_ids: list[str] = []
if search_term:
Expand Down Expand Up @@ -1173,7 +1193,7 @@ async def list_posts(
post.source_project_code, post.source_project_name,
post.source_system_code,
post.source_record_key,
post.corporate_entity_id, post.created_at,
post.corporate_entity_id, post.process_unit_id, post.created_at,
case
when $1::text is null then 0
when lower(coalesce(post.post_title, '')) like '%' || lower($1) || '%' then 0
Expand All @@ -1183,7 +1203,9 @@ async def list_posts(
count(*) over() as total_count
from source_post post
where (post.visibility_code = 'public'
or post.corporate_entity_id::text = any($2::text[]))
or (post.corporate_entity_id::text = any($2::text[])
and (cardinality($9::text[]) = 0
or post.process_unit_id::text = any($9::text[]))))
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")}
and (
$1::text is null
Expand Down Expand Up @@ -1381,6 +1403,7 @@ async def list_posts(
offset,
limit,
sort,
list(account.process_unit_ids),
)
visible = [row for row in rows if _can_see_post(account, row)]
labels = await _lookup_post_labels(conn, visible)
Expand Down Expand Up @@ -1431,7 +1454,7 @@ async def read_post(
"source_sales_pool_code, source_sales_pool_name, "
"source_customer_code, source_customer_name, source_project_code, source_project_name, "
"source_system_code, source_record_key, "
"corporate_entity_id, created_at "
"corporate_entity_id, process_unit_id, created_at "
f"from source_post where post_id = $1 and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}",
post_id,
)
Expand Down Expand Up @@ -1621,7 +1644,7 @@ async def _load_visible_post(
"""
select source_post.post_id, source_post.post_title, source_post.voc_type_code,
source_post.visibility_code, source_post.corporate_entity_id,
source_post.created_at, source_post.author_account_id,
source_post.process_unit_id, source_post.created_at, source_post.author_account_id,
source_post.source_process_unit_code, source_post.source_author_code,
source_post.source_company_code, source_post.source_customer_code,
source_post.source_project_code, source_post.source_sales_pool_code,
Expand Down
Loading