Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
25 changes: 19 additions & 6 deletions apps/services/gateway/gateway/routes/admin/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,19 @@

# The shared gateway engine (BO-10) — see the DB section below for why the
# names are re-exported rather than imported at each call site.
#
# H2 (MT-1c): `_tenant_session` IS `acb_common.db.tenant_session` — the
# tenant-bound context manager every request handler in this package now
# acquires its session through. The tenant is bound centrally from the
# caller's `app_user` row (`_with_resolved_access` → `bind_tenant`), which is
# the SAME source `get_org_id` below resolves — S1-1's explicit
# `organization_id` predicates stay as defense in depth on top of it.
# `get_db` (unbound) remains re-exported ONLY for `routes/agent_skills.py`,
# which reaches the seam through this module and is not this package's to
# convert.
from gateway.db import get_db # noqa: F401
from gateway.db import get_session_factory as _get_session_factory # noqa: F401
from gateway.db import tenant_session as _tenant_session # noqa: F401

# The ONE answer to "which tenant is this caller" (WS-29b, D-MT-1 (a)). Imported
# rather than re-derived: two implementations of that question is exactly how
Expand Down Expand Up @@ -95,14 +106,15 @@
# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ────────────────
#
# This package used to build its own engine here, with its own pool of 5+10.
# It now has none: `get_db` and `_get_session_factory` at the top of this module
# are re-exports of the shared seam, so every `from ._common import get_db` in
# this package keeps working with a single pool behind it.
# It now has none: `_tenant_session` and `_get_session_factory` at the top of
# this module are re-exports of the shared seam, so every
# `from ._common import _tenant_session` in this package keeps working with a
# single pool behind it.
#
# The re-export is deliberate rather than pointing each caller at `gateway.db`.
# Sibling modules import the name from here and the tests monkeypatch it *on the
# sibling* (`monkeypatch.setattr(groups, "get_db", ...)`), so the name has to
# stay resolvable through this module for both to keep working.
# sibling* (`monkeypatch.setattr(groups, "_tenant_session", ...)`), so the name
# has to stay resolvable through this module for both to keep working.


# ── Auth gate ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -655,7 +667,8 @@ async def provision_member(
hand-rolled INSERT would quietly skip. Spec ``colleague_onboarding.md`` §6
done-when 8.

Deliberately left to the CALLER: ``db.commit()`` (so an approval can mark
Deliberately left to the CALLER: the commit — the caller's
``_tenant_session`` block commits on clean exit (so an approval can mark
its request decided in the same transaction), ``invalidate_for``,
``record_admin_change`` (the audit action differs — `org.member_invited`
vs `org.access_request_approved`) and the response model.
Expand Down
22 changes: 9 additions & 13 deletions apps/services/gateway/gateway/routes/admin/access_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@
from gateway.routes.admin._common import (
_iso,
_log,
_tenant_session,
find_member,
get_db,
get_org_id,
invalidate_for,
provision_member,
Expand Down Expand Up @@ -133,9 +133,9 @@
#: for :func:`_load_request`: read-then-write is two statements, so two admins
#: clicking the same row both pass the read. Binding the same condition into
#: the UPDATE makes the loser's row-count zero, and because this runs BEFORE
#: ``db.commit()`` the 409 it raises discards that transaction's provisioning
#: with it — approve stays all-or-nothing under concurrency, not just in
#: sequence.
#: the commit (`_tenant_session` commits only on clean exit) the 409 it raises
#: discards that transaction's provisioning with it — approve stays
#: all-or-nothing under concurrency, not just in sequence.
_DECIDE_SQL = (
"UPDATE access_request SET status = :status, decided_by = :by, "
" decided_at = now() "
Expand Down Expand Up @@ -298,7 +298,8 @@ async def _decide(
without the condition here both writers would succeed and both callers
would be told they won. Zero rows updated means the row moved, so the whole
transaction — this stamp *and* any provisioning done above it — is
abandoned by raising before ``db.commit()``.
abandoned by raising before the commit (``_tenant_session`` commits only
on clean exit).
"""
if status not in REQUEST_STATUSES:
raise ValueError(f"unknown access_request status {status!r}")
Expand Down Expand Up @@ -370,8 +371,7 @@ async def list_access_requests(
Sits on the package's ``admin:members:read`` floor like every other read —
seeing who is locked out is part of reading the roster, not a new right.
"""
db = await get_db()
async with db:
async with _tenant_session() as db:
rows = (await db.execute(text(_PENDING_REQUESTS_SQL))).mappings().all()
return [_entry(dict(r)) for r in rows]

Expand Down Expand Up @@ -413,8 +413,7 @@ async def approve_access_request(
person out of a queue that renders only `pending`, and recorded an
`org.access_request_approved` for an approval that did not happen.
"""
db = await get_db()
async with db:
async with _tenant_session() as db:
request = await _load_request(db, email, allowed_statuses=("pending",))
org_id = await get_org_id(db, admin)

Expand Down Expand Up @@ -473,7 +472,6 @@ async def approve_access_request(

await _decide(db, member["email"].lower(), "approved", admin,
allowed_statuses=("pending",))
await db.commit()
roles = await roles_for_user(db, member["id"])
status = member["status"]

Expand Down Expand Up @@ -516,14 +514,12 @@ async def deny_access_request(
so the only thing it could achieve is a queue record that contradicts the
roster. Suspend or remove them from the roster instead.
"""
db = await get_db()
async with db:
async with _tenant_session() as db:
request = await _load_request(
db, email, allowed_statuses=("pending", "denied"),
)
await _decide(db, request["email"].lower(), "denied", admin,
allowed_statuses=("pending", "denied"))
await db.commit()

_log.info("access_request_denied", email=request["email"], by=admin.email,
attempts=request["attempt_count"])
Expand Down
25 changes: 7 additions & 18 deletions apps/services/gateway/gateway/routes/admin/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from fastapi import Depends, HTTPException
from gateway.routes.admin._common import (
_log,
get_db,
_tenant_session,
get_member,
get_org_id,
invalidate_for,
Expand Down Expand Up @@ -175,8 +175,7 @@ async def list_groups(
the roster) — the org has tens of people, so the full expansion is cheap
and saves the UI a per-group round trip.
"""
db = await get_db()
async with db:
async with _tenant_session() as db:
org_id = await get_org_id(db, admin)
rows = (
await db.execute(
Expand All @@ -202,8 +201,7 @@ async def create_group(
admin: UserContext = Depends(require_admin_user),
) -> GroupEntry:
slug = _clean_slug(req.slug)
db = await get_db()
async with db:
async with _tenant_session() as db:
org_id = await get_org_id(db, admin)
clash = (
await db.execute(
Expand All @@ -228,7 +226,6 @@ async def create_group(
"name": (req.display_name or slug).strip() or slug,
"desc": req.description or "", "by": admin.email},
)
await db.commit()

_log.info("group_created", slug=slug, by=admin.email)
record_admin_change(admin.email, "org.group_created", f"group:{slug}")
Expand All @@ -253,8 +250,7 @@ async def update_group(
``group:<slug>`` participant subjects, ``t:<slug>`` agent instance keys,
the ``center.<slug>`` pairing — and renaming it would orphan all three.
"""
db = await get_db()
async with db:
async with _tenant_session() as db:
org_id = await get_org_id(db, admin)
group = await _get_group(db, org_id, slug)
await db.execute(
Expand All @@ -268,7 +264,6 @@ async def update_group(
{"name": patch.display_name, "desc": patch.description,
"gid": group["id"]},
)
await db.commit()
group = await _get_group(db, org_id, slug)
entry = await _entry(db, group)

Expand All @@ -291,8 +286,7 @@ async def delete_group(
and silently emptying every room shared to ``group:<slug>`` — is a bigger
action than the admin asked for.
"""
db = await get_db()
async with db:
async with _tenant_session() as db:
org_id = await get_org_id(db, admin)
group = await _get_group(db, org_id, slug)
if slug in CENTER_GROUP_SLUGS:
Expand Down Expand Up @@ -324,7 +318,6 @@ async def delete_group(
text("DELETE FROM org_group WHERE id = CAST(:gid AS uuid)"),
{"gid": group["id"]},
)
await db.commit()

_log.info("group_deleted", slug=slug, by=admin.email)
record_admin_change(admin.email, "org.group_deleted", f"group:{slug}")
Expand Down Expand Up @@ -366,8 +359,7 @@ async def add_group_member(
),
)

db = await get_db()
async with db:
async with _tenant_session() as db:
org_id = await get_org_id(db, admin)
group = await _get_group(db, org_id, slug)
member = await get_member(db, org_id, req.email)
Expand Down Expand Up @@ -395,7 +387,6 @@ async def add_group_member(
"reason": f"group membership: {slug}", "by": admin.email},
)
granted = bool(getattr(result, "rowcount", 0))
await db.commit()

invalidate_for(member["email"])
_log.info("group_member_added", group=slug, email=member["email"],
Expand Down Expand Up @@ -431,8 +422,7 @@ async def remove_group_member(
DOES immediately remove the member from rooms shared to
``group:<slug>``: those expand membership at read time.)
"""
db = await get_db()
async with db:
async with _tenant_session() as db:
org_id = await get_org_id(db, admin)
group = await _get_group(db, org_id, slug)
member = await get_member(db, org_id, email)
Expand All @@ -449,7 +439,6 @@ async def remove_group_member(
status_code=404,
detail=f"'{email}' is not a member of '{slug}'.",
)
await db.commit()

invalidate_for(member["email"])
_log.info("group_member_removed", group=slug, email=member["email"],
Expand Down
5 changes: 2 additions & 3 deletions apps/services/gateway/gateway/routes/admin/me.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from acb_auth.permissions import CAPABILITIES
from fastapi import APIRouter, Depends

from gateway.routes.admin._common import get_db, get_org_id
from gateway.routes.admin._common import _tenant_session, get_org_id

me_router = APIRouter(prefix="/auth", tags=["auth"])

Expand Down Expand Up @@ -106,8 +106,7 @@ async def get_me(user: UserContext = Depends(get_current_user)) -> dict[str, Any
organization: dict[str, str] = {}
catalog: list[str] = []
try:
db = await get_db()
async with db:
async with _tenant_session() as db:
# The CALLER's organization, not the deployment's. This line used to
# report the `default` org's slug and display name to every
# signed-in member of every tenant, so the frontend's "which org am
Expand Down
Loading
Loading