Skip to content

Let a Bot's owner give it their own apps - #845

Closed
guidovizoso wants to merge 22 commits into
guido/connect-card-resumefrom
guido/owner-app-grants
Closed

guidovizoso wants to merge 22 commits into
guido/connect-card-resumefrom
guido/owner-app-grants

Conversation

@guidovizoso

@guidovizoso guidovizoso commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

What this changes

Stacked on the connect-card-resume PR, which this branch merges in; review that one first, and read this diff against it.

Giving a Bot an app has been an administrator's job, even when the app is one each person connects for themselves. So somebody who made a Bot five minutes ago and asked it to read their Gmail got "Ask an admin", for a mailbox that is theirs. Now a Bot's owner can give it their own apps, without an administrator, within limits an administrator sets.

What an owner can do

  • On the app card: "Let Research Bot use your Gmail?", one press. If they haven't connected the app yet, "Connect and allow" opens the consent first and gives the app once it is connected.
  • On the Bot's page, under Your apps: a switch for each app they could give, on where they gave it. An app an administrator gave is shown on and isn't theirs to switch off.
  • On the New Bot sheet: their connected apps arrive ticked, and the ones left ticked are given once the Bot exists. Somebody with nothing to give is asked nothing.
  • One press gives the app's read-only actions, the same as an administrator's one press. Anything that sends or writes is added one advertised action at a time through the existing grant route. The exception is the built-in Routines, whose every action keeps, changes or drops the person's own standing instructions: given only its reads, a Bot could list routines and never make one.
  • An owner takes back only what they gave (DELETE /api/plugins/for/:agentId/apps/:app/give). An administrator's grant stays.

The limits

  • Which apps. Only one an administrator has added and switched on, and that is open to owners. By default that is an app every person connects for themselves, and Routines. An app on the deployment's token, a Shared team account, or no account at all defaults to administrators only. An administrator can change it per app on its admin page ("Bot owners can give it to their Bots"), stored in the new mcp_servers.owner_grants (null means the default for the app's account, so an app switched from Personal to Shared falls back to admin-only by itself).
  • Which Bots. Only one the person owns. A Bot nobody owns, such as a package's public Bot, stays an administrator's to equip.
  • Whose account. Every owner grant is written plugin_grants.granted_as = owner, and a call under it on a Personal app always uses the asking person's own account, even on the owner's published Team Bot (credentialActorFor). Publishing a Bot must not hand every teammate the owner's mailbox. An administrator's grant on a Team Bot still uses the owner's account where the owner connected it, as before. A Shared app is the deployment's team account whoever the credential actor is (accountFor never reads it for shared), so the owner marker can't send it to a personal account.
  • Shared apps, where an administrator lets owners decide. An owner's grant on a Shared app whose rule is any is usable at once, with no separate audience approval. It records the Shared-account audience itself (shared_use_approvals.approved_as = owner): the people who can use the Bot when it is given (the owner for a private Bot, its published audience for a Team Bot) and never outside input. It is a snapshot like an administrator's approval, so publishing the Bot further later is still caught by the gate and asked of an administrator. It never overwrites an approval already there, and an administrator granting over it makes it theirs. The existing "writes ask first" team rule on Shared apps is unchanged. The audience goes when the owner takes the app back, or when an administrator switches the rule away from any (the route withdraws every owner-set audience at the app, and approvalFor ignores one left behind while the rule is not any). Pressing give again after the rule is reopened puts it back. The admin page's line for a Shared app says "Bot owners who give it to their Bots let those Bots use the team account."
  • A switch for all of it. A new capability, "Give apps to their own Bots" (ownerApps, on by default), lets an administrator turn the feature off for people.
  • An owner's grant never overwrites an administrator's: on a conflict it writes nothing and records nothing. An administrator granting over an owner's row makes it theirs.
  • An owner can't grant an action the app doesn't advertise now, or a bare server ref.

The app card gains an eighth state, owner_grantable, for an app the person looking may give themselves; nothing is filed for it.

Where it runs

  • New state that outlives a request? Three nullable columns in Postgres, each with a check constraint: mcp_servers.owner_grants and plugin_grants.granted_as (migration 0063_owner_app_grants), and shared_use_approvals.approved_as (migration 0064_owner_shared_audience, null read as admin). The ownerApps capability is stored with the other capabilities. Nothing is held in process.
  • What happens on the second replica? Every check (Bot owner, app state, owner_grants, capability) is read from Postgres on the request, so any process answers the same. During a rolling deploy, a replica from before this change writes grants without granted_as, which is read as admin, which is what that replica only ever made. One gap to know about: an old replica's credentialActorFor does not read granted_as, so while old and new replicas run side by side, a teammate's call on a published Team Bot, under a grant the owner made on a new replica, could go out on the owner's account if it lands on an old replica. It closes once the rollout finishes; a deployment with no published Team Bots is unaffected. Likewise an old replica approving over an owner-set audience leaves it marked owner, so the owner's take-back would remove it; that also closes with the rollout.
  • Anything serialised? Grants are inserts on the (kind, ref, agent) primary key: an owner's does nothing on a conflict, so two presses can't duplicate a grant or overwrite an administrator's. Taking back deletes only rows still marked owner, in one conditional delete, so an administrator's re-grant in between survives. Not serialised: two gives racing for the same Bot and app each pass the "already holds it" check, and each writes an app_access.owner_granted row, though only the press that inserted a grant writes its plugin_granted row.
  • Anything fanned out to a browser? No. The card and the Bot's page read the app's state again after a press.
  • New listener, port, or schedule? No.

Boundary and audit

  • Every acting call still goes through the gateway. An owner's grant changes what a Bot may call, not how: its calls are resolved, decided and audited as the asking person, and the Team Bot account rule above is in credentialActorFor, which that path already uses.
  • New refusals and new failures each write a row. Partly. Refusals on the new give and take-back routes write app_access.refused, and gives and take-backs write app_access.owner_granted / app_access.owner_revoked beside a plugin_granted / plugin_revoked row per action, marked grantedAs: owner. A Shared-app audience set by an owner's grant writes shared_use.owner_approved (the owner, the Bot, the app, the audience), and its withdrawal shared_use.owner_withdrawn with why (owner_took_back or owner_grants_changed). But an owner refused on POST /grants (adding one action) gets a 403 without a row, the same as every other refusal on that route today.
  • Nothing new is trusted from the client that the server can resolve itself. Ownership, the app's state, owner_grants, the capability and the list of advertised actions are all read on the server; the client names only the Bot and the app, or one ref that has to match an advertised action.

Changelog

  • "A Bot's owner can give it their own apps" under Unreleased, naming both migrations, with a paragraph on Shared apps. docs/plugins/composio.md "Shared apps" says owners can decide if an administrator lets them.

Merge notes

This and the handoff-progress-row PR both add a Drizzle migration numbered 0063 (0063_owner_app_grants and 0064_owner_shared_audience here, 0063_work_items_open_thread there). Whichever merges second has to regenerate its migrations with the next numbers; the SQL is unchanged.

How it was checked

  • New tests:
    • server/tests/owner-app-grants.integration.test.ts, against Postgres: the card's state is the owner's to give, and asks them to connect first when they haven't; one press gives the read-only actions marked as the owner's, with a trail; somebody else's Bot, an app kept for admins and an app nobody turned on are refused; the grant route lets an owner add one action and take back only their own grants; it refuses a ref the app doesn't advertise and records nothing over an admin's grant; the apps a person could give are the ones they connected and open to owners; and a Team Bot whose owner gave it Gmail uses each teammate's own account or asks them to connect, never the owner's, while the same Bot given Gmail by an admin uses the owner's, as before. For a Shared app whose rule is any: the owner's give is allowed by the shared-use gate at once with no admin approval, for the owner only on a private Bot and for teammates on a Team Bot published before the grant (not one published after); taking it back withdraws the audience, but leaves one an admin approved; the admin switching the rule to admin withdraws it and refuses calls, and a give after reopening restores it; an owner-set audience left behind is not read while the rule is not any; a Personal app's give sets none; an owner adding one action through POST /grants sets the same audience.
    • app/tests/admin-owner-grants-line.test.tsx: the admin page's line for a Shared app, a Personal app, and an admin-only app.
    • server/tests/app-access-state.test.ts: who may give an app by default for each kind of account, an administrator's choice winning either way.
    • app/tests/app-access-action.test.tsx, bot-access-your-apps.test.tsx, new-bot-sheet.test.tsx, plugin-mutation-refresh.test.ts: the card's question and one press, Connect and allow, the Your apps switches, and the New Bot sheet.
  • Not checked in a running app. The combined branch ran on a local stack in single-user dev mode, which has no second person, so neither an owner who isn't an admin nor a teammate on a published Team Bot could be exercised there. The tests above are the evidence for this PR.
  • The full server suite after the Shared-app change: 5,791 pass, 19 skip, 3 fail. The failures are environmental and also fail without this change: the Mastra test needs @mastra/core installed locally, and the two learning-langgraph real-provider tests time out waiting for a local provider. assertion-shape.test.ts passes. The app suite: 1,647 pass, 0 fail.

@guidovizoso

Copy link
Copy Markdown
Collaborator Author

Landed in #847.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant