Skip to content

feat(userspace): app_grants ledger feeds the capability broker#1404

Merged
jaylfc merged 1 commit into
devfrom
feat/app-grants-feeds-broker
Jun 23, 2026
Merged

feat(userspace): app_grants ledger feeds the capability broker#1404
jaylfc merged 1 commit into
devfrom
feat/app-grants-feeds-broker

Conversation

@jaylfc

@jaylfc jaylfc commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Implements the app-permissions reconciliation (broker enforces, app_grants is the ledger feeding it).

What

The userspace broker stays the runtime enforcer for gated capabilities (app.net/agent/llm/memory). The per-user app_grants ledger (built earlier this session) now feeds it: a capability the current user granted an app via the consent flow authorises it, on top of the per-app permissions_granted set.

Why this shape

One vocabulary, one enforcer. The ledger records who-granted-what-when per (user, app, capability); the broker reads it at call time. The merge is additive and best-effort: with no auth session or no ledger, the broker falls back to the per-app granted set, so nothing that worked before changes. First-party apps keep their pre-authorised all-gated set.

Changes

  • routes/userspace_apps.py: broker route unions the per-user ledger grants into granted for community apps (reads request.state.user_id optionally, no new 401).
  • tests/conftest.py + tests/userspace/conftest.py: init the lifespan-owned app_grants store (these fixtures bypass the lifespan; production inits it in app.py).
  • New tests: ledger-granted authorisation passes; a capability with no ledger grant stays denied.

Tests

159 passed across userspace + app_grants + app_permissions suites.

Summary by CodeRabbit

  • New Features

    • Enhanced authorization for app gated capabilities by recognizing per-user grants in addition to app-wide permissions, enabling more granular access control.
  • Tests

    • Added comprehensive tests validating per-user capability authorization logic for the app broker endpoint.

Per the reconciliation decision: the userspace broker stays the runtime
enforcer for gated capabilities, and the per-user app_grants ledger feeds it.
A capability the current user granted an app via the consent flow now
authorises it on top of the per-app permissions_granted set.

The merge is additive and best-effort: with no auth session or no ledger the
broker falls back to the per-app granted set, so existing behaviour is
unchanged. First-party apps keep their pre-authorised all-gated set.

Init the lifespan-owned app_grants store in the two test fixtures that bypass
the lifespan so the broker can consult it. New tests cover ledger-granted
authorisation and ledger-absent denial.
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The broker endpoint for community (non-first-party) apps now unions per-user capability grants from an optional app_grants ledger with the app's stored permissions_granted set when request.state.user_id is present. Test fixtures in both conftest.py files initialize app.state.app_grants, and two new tests validate the allow and deny cases.

Changes

Per-user app_grants broker authorization

Layer / File(s) Summary
Broker route: union per-user app_grants into capability authorization
tinyagentos/routes/userspace_apps.py
In the non-first-party branch, the granted capability set is expanded by fetching grants_store.granted_capabilities(uid, app_id) from the optional app_grants store and unioning the result with app["permissions_granted"]. Falls back to per-app set only when request.state.user_id or app_grants is absent.
Test fixture init and broker authorization tests
tests/conftest.py, tests/userspace/conftest.py, tests/test_routes_userspace_apps.py
Both client fixtures add await app.state.app_grants.init() before yielding the AsyncClient. Two new broker endpoint tests cover: a ledger match that allows the gated capability without set_permissions_granted, and a ledger mismatch that returns permission_denied.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • jaylfc/taOS#1120: Adds broker/gated capability allow vs. deny test scenarios for the same POST /api/userspace-apps/{app_id}/broker endpoint, directly overlapping with the new per-user ledger authorization tests.

Poem

🐰 A ledger of grants, per user, per app,
The broker now checks before closing the gap.
If your cap's in the list, you may pass right through,
If not, permission_denied — sorry, that's you!
The fixtures awaken the grants store at dawn,
And tests prove the gating is properly drawn. 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: introducing the app_grants ledger as a data source for the capability broker in the userspace module.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/app-grants-feeds-broker

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment on lines +250 to +259
# The userspace broker stays the runtime enforcer; the app_grants ledger
# feeds it (decision 24). A capability the current user granted this app
# via the consent flow also authorises it, on top of the per-app granted
# set. Additive and best-effort: no auth session or no ledger falls back
# to the per-app set, so nothing that worked before changes.
granted = set(app["permissions_granted"])
uid = getattr(request.state, "user_id", None)
grants_store = getattr(request.app.state, "app_grants", None)
if uid and grants_store is not None:
granted |= await grants_store.granted_capabilities(uid, app_id)

@gitar-bot gitar-bot Bot Jun 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Edge Case: Ledger merge isn't truly best-effort; store errors propagate

The comment describes the merge as "additive and best-effort: no auth session or no ledger falls back to the per-app set, so nothing that worked before changes." In practice two of those fallbacks don't behave as described:

  1. grants_store is getattr(request.app.state, 'app_grants', None), but app_grants is unconditionally assigned in create_app (tinyagentos/app.py), so it is effectively never None in production. The "no ledger" fallback therefore only triggers when uid is falsy.
  2. The broker route is auth-protected (not in AuthMiddleware exempt paths), so uid is normally set. The await grants_store.granted_capabilities(uid, app_id) call is unguarded: if the store is uninitialized (granted_capabilities raises RuntimeError when init() wasn't called) or the underlying query errors, the exception propagates and turns a previously-working broker call into a 500. That contradicts the "nothing that worked before changes" guarantee.

Production inits the store via the lifespan, so the practical risk is low, but a future client/test fixture that hits the broker without calling app_grants.init() will get a 500 instead of the intended per-app fallback. Consider wrapping the ledger lookup in a try/except (log + fall back to the per-app set) to make the merge genuinely best-effort.

Make the ledger merge genuinely best-effort by falling back to the per-app granted set if the store lookup fails.:

granted = set(app["permissions_granted"])
uid = getattr(request.state, "user_id", None)
grants_store = getattr(request.app.state, "app_grants", None)
if uid and grants_store is not None:
    try:
        granted |= await grants_store.granted_capabilities(uid, app_id)
    except Exception:
        logger.warning("app_grants lookup failed; falling back to per-app set", exc_info=True)

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

Note

Your trial team has used its Gitar budget, so automatic reviews are paused. Upgrade now to unlock full capacity. Comment "Gitar review" to trigger a review manually.
Learn more about usage limits

Code Review 👍 Approved with suggestions 0 resolved / 1 findings

Implements the app_grants ledger as an additive capability source for the broker. Wrap the ledger retrieval in a try-except block to ensure store errors propagate as intended for a best-effort fallback.

💡 Edge Case: Ledger merge isn't truly best-effort; store errors propagate

📄 tinyagentos/routes/userspace_apps.py:250-259

The comment describes the merge as "additive and best-effort: no auth session or no ledger falls back to the per-app set, so nothing that worked before changes." In practice two of those fallbacks don't behave as described:

  1. grants_store is getattr(request.app.state, 'app_grants', None), but app_grants is unconditionally assigned in create_app (tinyagentos/app.py), so it is effectively never None in production. The "no ledger" fallback therefore only triggers when uid is falsy.
  2. The broker route is auth-protected (not in AuthMiddleware exempt paths), so uid is normally set. The await grants_store.granted_capabilities(uid, app_id) call is unguarded: if the store is uninitialized (granted_capabilities raises RuntimeError when init() wasn't called) or the underlying query errors, the exception propagates and turns a previously-working broker call into a 500. That contradicts the "nothing that worked before changes" guarantee.

Production inits the store via the lifespan, so the practical risk is low, but a future client/test fixture that hits the broker without calling app_grants.init() will get a 500 instead of the intended per-app fallback. Consider wrapping the ledger lookup in a try/except (log + fall back to the per-app set) to make the merge genuinely best-effort.

Make the ledger merge genuinely best-effort by falling back to the per-app granted set if the store lookup fails.
granted = set(app["permissions_granted"])
uid = getattr(request.state, "user_id", None)
grants_store = getattr(request.app.state, "app_grants", None)
if uid and grants_store is not None:
    try:
        granted |= await grants_store.granted_capabilities(uid, app_id)
    except Exception:
        logger.warning("app_grants lookup failed; falling back to per-app set", exc_info=True)
🤖 Prompt for agents
Code Review: Implements the app_grants ledger as an additive capability source for the broker. Wrap the ledger retrieval in a try-except block to ensure store errors propagate as intended for a best-effort fallback.

1. 💡 Edge Case: Ledger merge isn't truly best-effort; store errors propagate
   Files: tinyagentos/routes/userspace_apps.py:250-259

   The comment describes the merge as "additive and best-effort: no auth session or no ledger falls back to the per-app set, so nothing that worked before changes." In practice two of those fallbacks don't behave as described:
   
   1. `grants_store` is `getattr(request.app.state, 'app_grants', None)`, but `app_grants` is unconditionally assigned in `create_app` (tinyagentos/app.py), so it is effectively never `None` in production. The "no ledger" fallback therefore only triggers when `uid` is falsy.
   2. The broker route is auth-protected (not in `AuthMiddleware` exempt paths), so `uid` is normally set. The `await grants_store.granted_capabilities(uid, app_id)` call is unguarded: if the store is uninitialized (`granted_capabilities` raises `RuntimeError` when `init()` wasn't called) or the underlying query errors, the exception propagates and turns a previously-working broker call into a 500. That contradicts the "nothing that worked before changes" guarantee.
   
   Production inits the store via the lifespan, so the practical risk is low, but a future client/test fixture that hits the broker without calling `app_grants.init()` will get a 500 instead of the intended per-app fallback. Consider wrapping the ledger lookup in a try/except (log + fall back to the per-app set) to make the merge genuinely best-effort.

   Fix (Make the ledger merge genuinely best-effort by falling back to the per-app granted set if the store lookup fails.):
   granted = set(app["permissions_granted"])
   uid = getattr(request.state, "user_id", None)
   grants_store = getattr(request.app.state, "app_grants", None)
   if uid and grants_store is not None:
       try:
           granted |= await grants_store.granted_capabilities(uid, app_id)
       except Exception:
           logger.warning("app_grants lookup failed; falling back to per-app set", exc_info=True)

Options

Display: compact → Showing less information.

Comment with these commands to change:

Compact
gitar display:verbose         

Important

Your trial ends in 4 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more.

Was this helpful? React with 👍 / 👎 | Gitar

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
tests/test_routes_userspace_apps.py (1)

570-608: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add one regression test for the “ledger unavailable” fallback path.

These tests validate the positive/negative ledger-decision path, but not the contract that broker behavior falls back cleanly when app_grants is unavailable/uninitialized.

🧪 Suggested test shape
`@pytest.mark.asyncio`
async def test_broker_gated_cap_falls_back_when_app_grants_unavailable(client):
    app = client._transport.app
    await _init_userspace_stores(app, app.state.data_dir)
    store = app.state.userspace_apps
    await _install_test_app(store, permissions=["app.net"])
    await store.set_permissions_granted("test-app", ["app.net"])

    # Simulate unavailable ledger in lifespan-bypassed context.
    await app.state.app_grants.close()

    resp = await client.post(
        "/api/userspace-apps/test-app/broker",
        json={"capability": "app.net.fetch", "args": {"url": "http://example.com"}},
    )
    assert resp.status_code == 200
    assert resp.json().get("error") != "permission_denied"
🤖 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 `@tests/test_routes_userspace_apps.py` around lines 570 - 608, Add a regression
test for the broker fallback when app_grants is unavailable/uninitialized. In
the userspace app broker tests near
test_broker_gated_cap_allowed_via_app_grants_ledger, add a new async test that
installs the app, grants the per-app permission, then closes or otherwise
disables app.state.app_grants before calling the
/api/userspace-apps/test-app/broker endpoint. Assert the broker still returns
200 and does not produce permission_denied, using the same
app.state.userspace_apps, _install_test_app, and app.state.app_grants symbols to
keep the test aligned with the existing ledger-path coverage.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@tests/conftest.py`:
- Around line 333-335: The test fixture initializes app_grants but never
releases it, so add a matching teardown step in the same fixture to close
app.state.app_grants after the test body completes. Locate the setup around
app.state.app_grants.init() and ensure the fixture cleanup path awaits the
corresponding close method on app.state.app_grants so SQLite handles are not
leaked and locking stays stable across tests.

In `@tests/userspace/conftest.py`:
- Around line 54-56: The userspace fixture initializes app_grants in setup but
never tears it down, so mirror the setup in the fixture cleanup by closing
app.state.app_grants during teardown. Update the conftest.py fixture that calls
app.state.app_grants.init() so it also performs the matching close on the same
app_grants lifecycle-managed object, keeping resource management consistent.

In `@tinyagentos/routes/userspace_apps.py`:
- Around line 257-259: The best-effort authorization path in the userspace app
handler can still raise a 500 when app_grants exists but has not been
initialized. Update the grant lookup around grants_store and
granted_capabilities() to verify the store is ready before calling it, or safely
skip/handle the call when initialization is incomplete so the fallback grant
calculation remains additive and broker requests do not fail unexpectedly.

---

Nitpick comments:
In `@tests/test_routes_userspace_apps.py`:
- Around line 570-608: Add a regression test for the broker fallback when
app_grants is unavailable/uninitialized. In the userspace app broker tests near
test_broker_gated_cap_allowed_via_app_grants_ledger, add a new async test that
installs the app, grants the per-app permission, then closes or otherwise
disables app.state.app_grants before calling the
/api/userspace-apps/test-app/broker endpoint. Assert the broker still returns
200 and does not produce permission_denied, using the same
app.state.userspace_apps, _install_test_app, and app.state.app_grants symbols to
keep the test aligned with the existing ledger-path coverage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 45617c49-8841-4432-9e26-93123a091bad

📥 Commits

Reviewing files that changed from the base of the PR and between 98ed0e2 and 842ca4b.

📒 Files selected for processing (4)
  • tests/conftest.py
  • tests/test_routes_userspace_apps.py
  • tests/userspace/conftest.py
  • tinyagentos/routes/userspace_apps.py

Comment thread tests/conftest.py
Comment on lines +333 to +335
# app_grants ledger (per-app capability grants) is lifespan-owned; tests that
# bypass the lifespan must init it so the userspace broker can consult it.
await app.state.app_grants.init()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Close app.state.app_grants in fixture teardown.

This fixture now initializes app_grants, but teardown does not close it. Add a matching close to avoid leaked SQLite handles and flaky test locking.

🔧 Proposed fix
     await office_docs.close()
     await feedback_store.close()
+    await app.state.app_grants.close()
     await app.state.qmd_client.close()
     await app.state.http_client.aclose()
🤖 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 `@tests/conftest.py` around lines 333 - 335, The test fixture initializes
app_grants but never releases it, so add a matching teardown step in the same
fixture to close app.state.app_grants after the test body completes. Locate the
setup around app.state.app_grants.init() and ensure the fixture cleanup path
awaits the corresponding close method on app.state.app_grants so SQLite handles
are not leaked and locking stays stable across tests.

Comment on lines +54 to +56
# app_grants ledger is lifespan-owned; init it so the broker can consult it.
await app.state.app_grants.init()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Mirror app_grants.init() with teardown close.

app_grants is now initialized in setup but never closed in teardown. Please close it to keep fixture resource management consistent.

🔧 Proposed fix
     await userspace_data.close()
     await userspace_apps.close()
+    await app.state.app_grants.close()
     await app.state.secrets.close()
     await app.state.qmd_client.close()
🤖 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 `@tests/userspace/conftest.py` around lines 54 - 56, The userspace fixture
initializes app_grants in setup but never tears it down, so mirror the setup in
the fixture cleanup by closing app.state.app_grants during teardown. Update the
conftest.py fixture that calls app.state.app_grants.init() so it also performs
the matching close on the same app_grants lifecycle-managed object, keeping
resource management consistent.

Comment on lines +257 to +259
grants_store = getattr(request.app.state, "app_grants", None)
if uid and grants_store is not None:
granted |= await grants_store.granted_capabilities(uid, app_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Uninitialized app_grants can turn best-effort authorization into a 500.

The current guard only checks for object presence, but granted_capabilities() raises when the store exists but is not initialized. That violates the additive fallback behavior and can fail broker calls unexpectedly.

🔧 Proposed fix
         uid = getattr(request.state, "user_id", None)
         grants_store = getattr(request.app.state, "app_grants", None)
         if uid and grants_store is not None:
-            granted |= await grants_store.granted_capabilities(uid, app_id)
+            try:
+                granted |= await grants_store.granted_capabilities(uid, app_id)
+            except RuntimeError:
+                # Best-effort path: preserve legacy per-app grants when
+                # the per-user ledger is unavailable/uninitialised.
+                pass
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
grants_store = getattr(request.app.state, "app_grants", None)
if uid and grants_store is not None:
granted |= await grants_store.granted_capabilities(uid, app_id)
uid = getattr(request.state, "user_id", None)
grants_store = getattr(request.app.state, "app_grants", None)
if uid and grants_store is not None:
try:
granted |= await grants_store.granted_capabilities(uid, app_id)
except RuntimeError:
# Best-effort path: preserve legacy per-app grants when
# the per-user ledger is unavailable/uninitialised.
pass
🤖 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/userspace_apps.py` around lines 257 - 259, The best-effort
authorization path in the userspace app handler can still raise a 500 when
app_grants exists but has not been initialized. Update the grant lookup around
grants_store and granted_capabilities() to verify the store is ready before
calling it, or safely skip/handle the call when initialization is incomplete so
the fallback grant calculation remains additive and broker requests do not fail
unexpectedly.

@jaylfc jaylfc merged commit a647da9 into dev Jun 23, 2026
9 of 10 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in TinyAgentOS Roadmap Jun 23, 2026
jaylfc added a commit that referenced this pull request Jun 23, 2026
#1404 (broker best-effort): the app_grants ledger lookup in the userspace
broker route was unguarded, so an uninitialised store or a query error turned
a previously-working broker call into a 500, contradicting the best-effort
comment. Wrap it in try/except that logs and falls back to the per-app granted
set.

#1405 (network origin validation): the grant API accepted any network: prefix,
including an empty or malformed origin, so a typo'd origin produced a ledger
row. Centralise the strict origin pattern as NET_ORIGIN_RE in capabilities.py
(single source of truth, the package parser now reuses it) plus a
is_valid_network_grant helper, and reject malformed network grants at the API
with a 400.

Tests: broker falls back instead of 500 on a raising ledger; malformed
network origins rejected; package parser still validates origins via the
shared pattern. 203 passed across the userspace + app-perms suites.
jaylfc added a commit that referenced this pull request Jun 24, 2026
…1429)

Backend for the app permission consent flow (#56), per Jay's answers (forks 31-34):
- Decisions gain an optional metadata JSON column (guarded ALTER migration
  mirroring board_audit; in _JSON_FIELDS; create() passthrough; DecisionIn field).
- capabilities.py: CAPABILITY_DESCRIPTIONS (one human line per known cap) +
  describe_capability() handling network:<origin> and unknowns.
- app_permissions.app_grant_decision_payload(): builds the multi_select consent
  card (options = requested caps, metadata.kind = app_grant). Not wired into
  install yet (live-verify session).
- answer route: _apply_app_grant side-effect writes granted (selected) / denied
  (rest) per capability to the existing app_grants ledger, best-effort.

Reuses the existing AppGrantsStore (app.state.app_grants, #1404/#1405). Tests:
metadata round-trip, metadata echo, app_grant answer writes grants, payload
builder, full capability-description coverage. 61 related tests pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

1 participant