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
1 change: 1 addition & 0 deletions changelog.d/1788-social-read-owner.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Bot Social read served from the channel answers only for the Bot the actor owns: a `body.botId` the actor does not own is refused with `bot/forbidden` before the handler registers that Bot's identity or syncs its grants ([#1788](https://github.com/ThinkFlowLab/FlyBot/issues/1788)).
9 changes: 8 additions & 1 deletion core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,15 @@ export function install(rt) {

// FlyBot plugin endpoints (src/host/hooks.js) served on this channel: reads go to
// any signed-in actor; plugin writes are deployment-wide until plugins declare
// owners, so they follow the shared rule.
// owners, so they follow the shared rule. One read is not deployment-wide: a friend
// list and its grants belong to the Bot's owner, so a body.botId the actor does not
// own is refused here, before the handler registers a Bot Social identity or syncs
// its grants (#1788, owner-decided tightening). Every other plugin read stays open
// to every signed-in actor.
if (ep.startsWith('flybot/')) {
if (ep === 'flybot/social/share-friends' && body?.botId !== undefined) {
return botOwnedBy(actor, body.botId) ? ALLOW : deny('bot/forbidden', 'You do not own that Bot')
}
if (rt.flybotIsRead?.(ep.slice('flybot/'.length)) === true) return ALLOW
// Held-send decisions are per-owner enforced in hooks.js — the approval belongs
// to the sender Bot's owner — so deciding does not follow the shared rule (#52).
Expand Down
36 changes: 36 additions & 0 deletions core/test/authz.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -469,3 +469,39 @@ test('#613: a member cannot take another owner’s Main Bot’s place', async (t
// And Bob's own Bot keeps the plain promotion the deployment-wide list gives it.
assert.equal(rt.authorize(actorB, 'set-main', { id: 'b1', main: true }).ok, true)
})

// #1788: a Bot Social read served from the channel answered for any Bot whose id the
// caller put in the body — the handler registered that Bot's identity and synced its
// grants while serving it, so a member read another member's friends and wrote on their
// behalf. The read stays a read: the refusal happens in authz, before the handler runs.
test('a Bot Social read answers only for the actor\'s own Bot (#1788)', async (t) => {
const saved = {
...TEAM,
bots: {
...TEAM.bots,
a1: { id: 'a1', sessionId: 'a1', name: 'A', instructions: '', createdAt: 2, ownerId: 'owner-1' },
},
}
const { rt } = runtime(t, saved)
await rt.load()
// The plugin registers this endpoint with `{ read: true }` (plugins/social/src/social.js).
rt.flybotIsRead = name => name === 'social/share-friends'
const actorA = await rt.authenticate((await redeemOwner(rt)).sessionToken)
const actorB = await rt.authenticate((await redeemOwner(rt)).sessionToken)
assert.equal(actorB.memberId, 'owner-2')

const foreign = rt.authorize(actorB, 'flybot/social/share-friends', { botId: 'a1' })
assert.equal(foreign.ok, false, `another member's Bot is refused: ${JSON.stringify(foreign)}`)
assert.equal(foreign.code, 'bot/forbidden')
assert.match(foreign.message, /You do not own that Bot/)

const own = rt.authorize(actorA, 'flybot/social/share-friends', { botId: 'a1' })
assert.equal(own.ok, true, `its own Bot still reads: ${JSON.stringify(own)}`)

// No actor is the single-member install exactly as it was: unenforced, nothing narrows.
const legacy = runtime(t, saved)
await legacy.rt.load()
assert.equal(legacy.rt.authorize(null, 'flybot/social/share-friends', { botId: 'a1' }).ok, true)
// And once authz applies, a missing actor is still refused before any Bot is read.
assert.equal(rt.authorize(null, 'flybot/social/share-friends', { botId: 'a1' }).code, 'bot/unauthorized')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion patches/ds-bot/files/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,15 @@ export function install(rt) {

// FlyBot plugin endpoints (src/host/hooks.js) served on this channel: reads go to
// any signed-in actor; plugin writes are deployment-wide until plugins declare
// owners, so they follow the shared rule.
// owners, so they follow the shared rule. One read is not deployment-wide: a friend
// list and its grants belong to the Bot's owner, so a body.botId the actor does not
// own is refused here, before the handler registers a Bot Social identity or syncs
// its grants (#1788, owner-decided tightening). Every other plugin read stays open
// to every signed-in actor.
if (ep.startsWith('flybot/')) {
if (ep === 'flybot/social/share-friends' && body?.botId !== undefined) {
return botOwnedBy(actor, body.botId) ? ALLOW : deny('bot/forbidden', 'You do not own that Bot')
}
if (rt.flybotIsRead?.(ep.slice('flybot/'.length)) === true) return ALLOW
// Held-send decisions are per-owner enforced in hooks.js — the approval belongs
// to the sender Bot's owner — so deciding does not follow the shared rule (#52).
Expand Down
36 changes: 36 additions & 0 deletions patches/ds-bot/files/test/authz.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -469,3 +469,39 @@ test('#613: a member cannot take another owner’s Main Bot’s place', async (t
// And Bob's own Bot keeps the plain promotion the deployment-wide list gives it.
assert.equal(rt.authorize(actorB, 'set-main', { id: 'b1', main: true }).ok, true)
})

// #1788: a Bot Social read served from the channel answered for any Bot whose id the
// caller put in the body — the handler registered that Bot's identity and synced its
// grants while serving it, so a member read another member's friends and wrote on their
// behalf. The read stays a read: the refusal happens in authz, before the handler runs.
test('a Bot Social read answers only for the actor\'s own Bot (#1788)', async (t) => {
const saved = {
...TEAM,
bots: {
...TEAM.bots,
a1: { id: 'a1', sessionId: 'a1', name: 'A', instructions: '', createdAt: 2, ownerId: 'owner-1' },
},
}
const { rt } = runtime(t, saved)
await rt.load()
// The plugin registers this endpoint with `{ read: true }` (plugins/social/src/social.js).
rt.flybotIsRead = name => name === 'social/share-friends'
const actorA = await rt.authenticate((await redeemOwner(rt)).sessionToken)
const actorB = await rt.authenticate((await redeemOwner(rt)).sessionToken)
assert.equal(actorB.memberId, 'owner-2')

const foreign = rt.authorize(actorB, 'flybot/social/share-friends', { botId: 'a1' })
assert.equal(foreign.ok, false, `another member's Bot is refused: ${JSON.stringify(foreign)}`)
assert.equal(foreign.code, 'bot/forbidden')
assert.match(foreign.message, /You do not own that Bot/)

const own = rt.authorize(actorA, 'flybot/social/share-friends', { botId: 'a1' })
assert.equal(own.ok, true, `its own Bot still reads: ${JSON.stringify(own)}`)

// No actor is the single-member install exactly as it was: unenforced, nothing narrows.
const legacy = runtime(t, saved)
await legacy.rt.load()
assert.equal(legacy.rt.authorize(null, 'flybot/social/share-friends', { botId: 'a1' }).ok, true)
// And once authz applies, a missing actor is still refused before any Bot is read.
assert.equal(rt.authorize(null, 'flybot/social/share-friends', { botId: 'a1' }).code, 'bot/unauthorized')
})
Loading