Skip to content
Open
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
55 changes: 39 additions & 16 deletions tinyagentos/routes/agent_auth_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ class CreateAuthRequest(BaseModel):
class ApproveBody(BaseModel):
granted_scopes: list[str]
project_id: Optional[str] = None
defer_binding: bool = False
Comment on lines 93 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add deferred-binding approval tests.

This PR adds a public approval mode but changes no tests. Cover deferred project-scoped approval without project_id (unbound token/grants and no membership/a2a), plus the non-deferred validation/bound behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/agent_auth_requests.py` around lines 93 - 96, Add tests
for ApproveBody approval flows covering deferred project-scoped approval without
project_id, asserting unbound tokens/grants and no membership or A2A creation,
and covering non-deferred approval validation plus project binding behavior.
Reuse the existing approval, token, membership, and A2A test fixtures and
helpers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Defer flag ignored 🐞 Bug ≡ Correctness

ApproveBody adds defer_binding, but _do_approve() never passes body.defer_binding into
approve_request_record(), so the helper always uses its default False and the deferred-binding
path is unreachable from the approve API. This breaks the intended “Assign later” behavior
(including skipping the project_id-required guard) and makes the new field/docstrings misleading.
Agent Prompt
### Issue description
The approve endpoint accepts `defer_binding` in `ApproveBody`, but `_do_approve()` does not forward it into `approve_request_record()`. Therefore, `approve_request_record(..., defer_binding=...)` always receives the default `False`, and the new deferred-binding logic cannot be exercised.

### Issue Context
The PR’s new behavior is implemented in `approve_request_record()` and described in its docstring, but the route handler `_do_approve()` is the consent approval path that should activate it based on the request body.

### Fix Focus Areas
- tinyagentos/routes/agent_auth_requests.py[787-818]
- tinyagentos/routes/agent_auth_requests.py[93-97]
- tinyagentos/routes/agent_auth_requests.py[345-425]

### Implementation notes
- Pass `defer_binding=body.defer_binding` in the `_do_approve()` call to `approve_request_record()`.
- Add/extend tests to cover approving with `defer_binding=true` (e.g., project-scoped scopes granted with no `project_id` should succeed, mint token with no project claim, and skip membership/a2a sync until assign-agent is called).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



class AssignAgentBody(BaseModel):
Expand Down Expand Up @@ -350,6 +351,7 @@ async def approve_request_record(
decided_by: str,
project_id: str | None = None,
display_name: str | None = None,
defer_binding: bool = False,
) -> dict:
"""Register an agent, mint its token, write grants + relationships +
membership + a2a sync, and record the decision.
Expand All @@ -362,11 +364,18 @@ async def approve_request_record(
the consent route it is the approving admin's user_id, for invite auto-mode
it is the invite's ``created_by``.

When ``defer_binding`` is true the token and grants are minted UNBOUND
(``project_id=None``): the project_id-required 400 guard is skipped, no
membership row or a2a channel is created, and project-scoped calls 403
until the agent is later bound to a project via
``POST /api/projects/{id}/members/assign-agent``. This complements the
create-new path (which binds to the picked project at mint time).

Returns ``{"status": "accepted", "canonical_id": ...}``.

Raises ``HTTPException`` for the same guard failures as the consent route:
a project-scoped grant without a project_id (400), or an active-handle
collision (409).
a project-scoped grant without a project_id (400, unless deferred), or an
active-handle collision (409).
"""
auth_store = _get_auth_requests_store(request)

Expand All @@ -388,22 +397,32 @@ async def approve_request_record(

# project_tasks and the canvas scopes bind the token to a specific project
# and add a membership row, so require a real project_id for these grants.
# The check uses ``project_id`` the EXPLICIT picker value the human (or
# the invite) supplied NOT the agent-supplied fallback that produced
# The check uses ``project_id`` -- the EXPLICIT picker value the human (or
# the invite) supplied -- NOT the agent-supplied fallback that produced
# ``effective_project``. Never fall back to the agent-supplied project_id for
# these grants: POST /api/agents/auth-requests is unauthenticated, so the
# request could name any existing project the operator never validated. A
# blank/None project_id is not a real binding and must fail closed exactly
# like a missing one; the redeem path passes the invite's project (always
# non-empty for a project invite), so it passes this guard.
#
# When ``defer_binding`` is set the operator has explicitly chosen to mint
# the token unbound: the project-scoped grants are written with
# project_id=None and the operator will bind them to a project later via
# POST /api/projects/{id}/members/assign-agent. Skip the 400 in that path.
needs_project = bool(set(granted_scopes) & _PROJECT_SCOPES)
if needs_project and not (project_id and project_id.strip()):
if needs_project and not defer_binding and not (project_id and project_id.strip()):
missing = sorted(set(granted_scopes) & _PROJECT_SCOPES)
raise HTTPException(
status_code=400,
detail=f"project_id is required when granting {missing}",
)

# When deferring, the token and grants are minted unbound (project_id=None)
# regardless of effective_project; project-scoped calls 403 until the agent
# is bound to a project later via assign-agent.
binding_project = None if defer_binding else effective_project

registry = _get_registry_store(request)
private_pem, _public_pem = _get_keypair(request)
grants_store = _get_grants_store(request)
Expand Down Expand Up @@ -438,7 +457,7 @@ async def approve_request_record(
# fallback for global scopes) would be safe only by invariant. Gate and
# bind on project_id so cross-project escalation is impossible by
# construction (kilo review, taOS #1862).
if project_id and set(granted_scopes) & _PROJECT_SCOPES:
if not defer_binding and project_id and set(granted_scopes) & _PROJECT_SCOPES:
existing_cid = existing_active["canonical_id"]
token = mint_registry_token(
existing_cid,
Expand Down Expand Up @@ -527,18 +546,22 @@ async def approve_request_record(
),
)

# Issue the identity token.
# Issue the identity token. When deferring, binding_project is None so the
# token carries no project_id claim and is valid for identity/global calls
# only; project-scoped calls 403 until assign-agent binds it.
token = mint_registry_token(
canonical_id,
private_pem,
user_id=decided_by,
framework=record["framework"],
project_id=effective_project,
project_id=binding_project,
)

# Record grants for each approved scope.
# Record grants for each approved scope. When deferring, grants are written
# unbound (project_id=None); assign-agent later writes the project-bound
# grant that makes project-scoped calls succeed.
for scope in granted_scopes:
await grants_store.add_grant(canonical_id, scope, tier="once", project_id=effective_project)
await grants_store.add_grant(canonical_id, scope, tier="once", project_id=binding_project)
# Also write a RelationshipManager permission edge so the existing
# permission-check path (can_communicate etc.) is aware of the agent.
await rel_mgr.set_permission(canonical_id, "taos-instance", scope)
Expand All @@ -547,8 +570,8 @@ async def approve_request_record(
# member of that project so it shows up in the project's Members and joins the
# project a2a channel (membership is synced into the channel). Best-effort: a
# membership failure never blocks the approval, which the token + grant already
# authorize.
if effective_project and set(granted_scopes) & _PROJECT_SCOPES:
# authorize. Skipped entirely when deferring (binding_project is None).
if binding_project and set(granted_scopes) & _PROJECT_SCOPES:
try:
pstore = getattr(request.app.state, "project_store", None)
if pstore is not None:
Expand All @@ -563,14 +586,14 @@ async def approve_request_record(
# how the consent card scopes are narrowed.
if "project_tasks" in granted_scopes or granted_canvas:
await pstore.add_member(
project_id=effective_project,
project_id=binding_project,
member_id=canonical_id,
member_kind="native",
role="member",
)
if granted_canvas:
await pstore.set_member_canvas(
project_id=effective_project,
project_id=binding_project,
member_id=canonical_id,
can_read=("canvas_read" in granted_canvas),
can_write=("canvas_write" in granted_canvas),
Expand All @@ -581,14 +604,14 @@ async def approve_request_record(
await ensure_a2a_channel(
request.app.state.chat_channels,
pstore,
effective_project,
binding_project,
config=getattr(request.app.state, "config", None),
)
except Exception: # noqa: BLE001 - membership is best-effort, never blocks approval
logger.warning(
"auth-approve: could not sync %s membership/a2a channel for project %s",
canonical_id,
effective_project,
binding_project,
exc_info=True,
)

Expand Down
Loading