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
24 changes: 24 additions & 0 deletions apps/backend/src/routes/api-v1/bot-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Effect, Exit } from "effect"
import { describe, expect, it } from "vitest"
import { requireBotScope } from "./bot-auth"

const check = (scopes: ReadonlyArray<string> | null) =>
Effect.runSync(Effect.exit(requireBotScope({ scopes }, "channels:read")))

describe("requireBotScope", () => {
it("lets a bot holding the scope through", () => {
expect(Exit.isSuccess(check(["messages:read", "channels:read"]))).toBe(true)
})

it("lets a bot with no scopes recorded through, as a member", () => {
expect(Exit.isSuccess(check(null))).toBe(true)
})

it("refuses a bot whose scopes leave it out", () => {
const error = Effect.runSync(
Effect.flip(requireBotScope({ scopes: ["messages:read", "channels:write"] }, "channels:read")),
)
expect(error._tag).toBe("UnauthorizedError")
expect(error.message).toContain("channels:read")
})
})
11 changes: 11 additions & 0 deletions apps/backend/src/routes/api-v1/bot-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,14 @@ export const botPrincipal = (bot: {
}),
botScopes: bot.scopes === null ? null : bot.scopes.filter(isApiScope),
})

/** A bot with scopes recorded must hold `scope`; one without keeps a member's, as in `botPrincipal`. */
export const requireBotScope = (bot: { readonly scopes: ReadonlyArray<string> | null }, scope: ApiScope) =>
bot.scopes === null || bot.scopes.includes(scope)
? Effect.void
: Effect.fail(
new UnauthorizedError({
message: `This bot lacks the ${scope} scope`,
detail: `Add ${scope} to the bot's scopes`,
}),
)
24 changes: 22 additions & 2 deletions apps/backend/src/routes/api-v1/bots.http.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* The public bot API for bots that live outside Hazel (`@hazel/domain/http` `api-v1-bots`):
* redeeming an install grant in the `OrgObject` its code names, opening a thread through
* `channel.createThread` in the channel's org, and the org's user lookup. Each runs as the bot
* `channel.createThread` in the channel's org, and the org's user and channel lookups. Each runs as the bot
* authenticated by its token (`./bot-auth.ts`).
*/
import { HttpApiBuilder } from "effect/http-api"
Expand All @@ -16,6 +16,7 @@ import {
BotInstallGrantInvalidError,
BotInstallRedeemedResponse,
ChannelNotFoundError,
ApiV1ChannelsResponse,
ApiV1UsersResponse,
ThreadResponse,
} from "@hazel/domain/http"
Expand All @@ -33,11 +34,12 @@ import {
hashInstallCode,
ORGANIZATION_USERS_MAX_IDS,
organizationOfInstallCode,
OrganizationChannelsCall,
OrganizationUsersCall,
RedeemBotInstallGrantCall,
} from "../../worker/objects/org/bot-install-grants"
import { providePrincipal } from "../../worker/objects/principal"
import { authenticateBotFromToken, botPrincipal } from "./bot-auth"
import { authenticateBotFromToken, botPrincipal, requireBotScope } from "./bot-auth"

const internal = (message: string) => (cause: unknown) =>
Effect.logError(message, { cause }).pipe(
Expand Down Expand Up @@ -151,6 +153,24 @@ export const HttpApiV1BotsLive = HttpApiBuilder.group(HazelApi, "api-v1-bots", (
}),
Effect.withSpan("ApiV1.listOrganizationUsers"),
),
)
.handle("listOrganizationChannels", ({ params }) =>
Effect.gen(function* () {
const bot = yield* authenticateBotFromToken
yield* requireBotScope(bot, "channels:read")
const channels = yield* callObject(
OrganizationChannelsCall,
orgTarget(params.organizationId),
{ botId: bot.id, botUserId: bot.userId },
)
return new ApiV1ChannelsResponse({ data: channels })
}).pipe(
Effect.catchTags({
DatabaseError: internal("Database error while listing channels"),
ObjectCallError: internal("The organization is unavailable"),
}),
Effect.withSpan("ApiV1.listOrganizationChannels"),
),
),
),
)
37 changes: 37 additions & 0 deletions apps/backend/src/routes/object-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,43 @@ describe("public API (v1) for bots outside Hazel", () => {
))
})

describe("public API (v1) bot channels", () => {
it("lists the channels a bot can post in, and refuses a bot without channels:read", () =>
run(
Effect.gen(function* () {
const reader = {
botId: uuid() as BotId,
botUserId: uuid() as UserId,
token: `hzl_bot_${uuid()}`,
}
const writer = {
botId: uuid() as BotId,
botUserId: uuid() as UserId,
token: `hzl_bot_${uuid()}`,
}
const world = yield* makeWorld([reader.botUserId, writer.botUserId])
// The org's computed access for each bot user, as `syncChannelAccess` writes it.
for (const bot of [reader, writer]) {
world.org.storage.db
.prepare(
`INSERT INTO channel_access (userId, channelId, organizationId) VALUES (?, ?, ?)`,
)
.run(bot.botUserId, world.channelId, world.orgId)
}
yield* insertDirectoryBot(world, { ...reader, scopes: ["channels:read", "messages:write"] })
yield* insertDirectoryBot(world, { ...writer, scopes: ["messages:write", "channels:write"] })
const path = `/api/v1/organizations/${world.orgId}/bot-channels`

const allowed = yield* botHttp(world, reader.token)
expect(yield* allowed.json(yield* allowed.request(path))).toEqual({
data: [{ id: world.channelId, name: "general", private: false }],
})
const refused = yield* botHttp(world, writer.token)
expect((yield* refused.request(path)).status).toBe(401)
}),
))
})

describe("incoming webhooks", () => {
const seedWebhook = (world: World, options: { readonly enabled: boolean }) =>
Effect.gen(function* () {
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/worker/objects/call-receivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { listMessages, ListMessagesCall, messageThread, MessageThreadCall } from
import { liveAccess, LiveAccessCall } from "./channel/live-access"
import type { ObjectKind } from "./kinds"
import {
organizationChannels,
OrganizationChannelsCall,
organizationUsers,
OrganizationUsersCall,
redeemBotInstallGrant,
Expand Down Expand Up @@ -115,6 +117,7 @@ export const callReceivers: Record<ObjectKind, ReadonlyMap<string, Receiver>> =
receiver(IsOrgAdminCall, isOrgAdmin),
receiver(RedeemBotInstallGrantCall, redeemBotInstallGrant),
receiver(OrganizationUsersCall, organizationUsers),
receiver(OrganizationChannelsCall, organizationChannels),
]),
channel: byName([
receiver(ListMessagesCall, listMessages),
Expand Down
63 changes: 59 additions & 4 deletions apps/backend/src/worker/objects/directory-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/
import { Database } from "@hazel/db"
import { CurrentUser } from "@hazel/domain"
import type { ChannelId, NotificationId, OrganizationId, UserId } from "@hazel/schema"
import type { BotId, ChannelId, NotificationId, OrganizationId, UserId } from "@hazel/schema"
import { makeFakeSocket } from "@hazel/sync-do/testing"
import { Cause, Effect, Exit, Option, type Scope } from "effect"
import { Hex } from "effect/encoding"
Expand All @@ -15,7 +15,12 @@ import { decodeExit, encodePayload, type HazelRpcTag, rpcFor } from "../../rpc/d
import { Directory } from "../../services/directory"
import { callObject } from "./calls"
import { channelTarget, orgTarget, userTarget } from "./kinds"
import { hashInstallCode, OrganizationUsersCall, RedeemBotInstallGrantCall } from "./org/bot-install-grants"
import {
hashInstallCode,
OrganizationChannelsCall,
OrganizationUsersCall,
RedeemBotInstallGrantCall,
} from "./org/bot-install-grants"
import { ObjectStubs } from "./protocol"
import { encodeInvokePrincipal } from "./principal"
import { makeTestObjects, type TestObject, type TestObjects } from "./testing"
Expand Down Expand Up @@ -128,7 +133,7 @@ const directoryRows = <A>(objects: TestObjects, statement: string) =>
Effect.orDie,
)

const createChannel = (org: TestObject, s: Seed, name: string) =>
const createChannel = (org: TestObject, s: Seed, name: string, type: "public" | "private" = "public") =>
Effect.map(
call(
org,
Expand All @@ -137,7 +142,7 @@ const createChannel = (org: TestObject, s: Seed, name: string) =>
{
name,
icon: null,
type: "public",
type,
organizationId: s.orgId,
parentChannelId: null,
sectionId: null,
Expand Down Expand Up @@ -408,6 +413,56 @@ describe("directory path", () => {
}),
))

it("a bot lists the channels it can post in: public ones, and private ones it is in", () =>
run(
Effect.gen(function* () {
const objects = yield* makeTestObjects
const { seed: s, org } = yield* seed(objects)
const created = ok<{ data: { id: string; userId: string } }>(
yield* call(
org,
s.orgId,
"bot.create",
{ name: "Maple", scopes: ["channels:read"], organizationId: s.orgId },
s.owner,
),
)
const botId = created.data.id as BotId
const botUserId = created.data.userId as UserId
const general = yield* createChannel(org, s, "general")
// The bot is added to one private channel; the update recomputes its access.
const incidents = yield* createChannel(org, s, "incidents", "private")
org.storage.db
.prepare(`INSERT INTO channel_members (channelId, userId) VALUES (?, ?)`)
.run(incidents, botUserId)
ok(yield* call(org, s.orgId, "channel.update", { id: incidents, name: "incidents" }, s.owner))
yield* createChannel(org, s, "leadership", "private")
const gone = yield* createChannel(org, s, "archive")
ok(yield* call(org, s.orgId, "channel.delete", { id: gone }, s.owner))

const list = (orgId: OrganizationId) =>
callObject(OrganizationChannelsCall, orgTarget(orgId), { botId, botUserId }).pipe(
Effect.provideService(ObjectStubs, objects.stubs),
Effect.provideContext(objects.directory),
Effect.exit,
)
expect(ok(yield* list(s.orgId))).toEqual([
{ id: general, name: "general", private: false },
{ id: incidents, name: "incidents", private: true },
])

// An org the bot is not installed in refuses.
const stranger = yield* seed(objects)
expect(errorTag(yield* list(stranger.seed.orgId))).toBe("BotNotInstalledError")

// So does a deleted org, whose member and access rows are left behind.
org.storage.db
.prepare(`UPDATE organizations SET deletedAt = '2026-10-31 00:00:00.000' WHERE id = ?`)
.run(s.orgId)
expect(errorTag(yield* list(s.orgId))).toBe("BotNotInstalledError")
}),
))

it("invitation.accept joins the org and #general, and only once", () =>
run(
Effect.gen(function* () {
Expand Down
88 changes: 73 additions & 15 deletions apps/backend/src/worker/objects/org/bot-install-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@
* org's own store holds the grant, so a code works the moment the consent page hands it out.
*
* Also the public bot API's user lookup (`GET /api/v1/organizations/:id/users`), read from the
* org's `users` projection.
* org's `users` projection, and its channel list (`GET /api/v1/organizations/:id/bot-channels`), read
* from the bot user's `channel_access`.
*/
import { Database, SQLite, schema } from "@hazel/db"
import { BOT_INSTALL_GRANT_TTL_MS } from "@hazel/domain/bot-install"
import { ApiV1User, BotInstallGrantInvalidError, BotNotInstalledError } from "@hazel/domain/http"
import {
ApiV1Channel,
ApiV1User,
BotInstallGrantInvalidError,
BotNotInstalledError,
} from "@hazel/domain/http"
import { BotId, OrganizationId, UserId } from "@hazel/schema"
import { Effect, Option, Schema } from "effect"
import { Hex } from "effect/encoding"
Expand Down Expand Up @@ -121,23 +127,40 @@ export const OrganizationUsersCall = defineCall("org.botUsers", "org", {
error: BotNotInstalledError,
})

/** The org is live and the bot's user is a member here: the bot is installed in this org. */
const requireBotMember = Effect.fn("OrgObject.requireBotMember")(function* (input: {
readonly botId: BotId
readonly botUserId: UserId
}) {
const organizationId = yield* requireOrganizationId
const db = yield* Database.Database
// A deleted org keeps its member and access rows, so it is checked on its own.
const organization = yield* db.first(
SQLite.from(schema.organizationsTable)
.select("id")
.where(($) => [$.id.eq(organizationId), $.deletedAt.isNull()])
.limit(1),
)
const membership = yield* db.first(
SQLite.from(schema.organizationMembersTable)
.select("id")
.where(($) => [
$.organizationId.eq(organizationId),
$.userId.eq(input.botUserId),
$.deletedAt.isNull(),
])
.limit(1),
)
if (Option.isNone(organization) || Option.isNone(membership))
return yield* new BotNotInstalledError({ botId: input.botId, orgId: organizationId })
return organizationId
})

/** The org's people (and bots) among `ids`, for a bot that is a member here. */
export const organizationUsers = Effect.fn("OrgObject.botUsers")(
function* (payload: typeof OrganizationUsersCall.payload.Type) {
const organizationId = yield* requireOrganizationId
yield* requireBotMember(payload)
const db = yield* Database.Database
const membership = yield* db.first(
SQLite.from(schema.organizationMembersTable)
.select("id")
.where(($) => [
$.organizationId.eq(organizationId),
$.userId.eq(payload.botUserId),
$.deletedAt.isNull(),
])
.limit(1),
)
if (Option.isNone(membership))
return yield* new BotNotInstalledError({ botId: payload.botId, orgId: organizationId })
if (payload.ids.length === 0) return []
const users = yield* db.run(
SQLite.from(schema.usersTable)
Expand All @@ -154,3 +177,38 @@ export const organizationUsers = Effect.fn("OrgObject.botUsers")(
},
Effect.catchTag("DatabaseError", (error) => Effect.die(error)),
)

export const OrganizationChannelsCall = defineCall("org.botChannels", "org", {
payload: Schema.Struct({ botId: BotId, botUserId: UserId }),
success: Schema.Array(ApiV1Channel),
error: BotNotInstalledError,
})

/**
* The channels the bot can post in here, by name: its `channel_access` (every public channel, and
* the private ones it is a member of), without threads, direct messages or deleted channels.
*/
export const organizationChannels = Effect.fn("OrgObject.botChannels")(
function* (payload: typeof OrganizationChannelsCall.payload.Type) {
const organizationId = yield* requireBotMember(payload)
const db = yield* Database.Database
// One join, not an `IN` over the ids: a Durable Object binds at most 100 parameters.
const channels = yield* db.run(
SQLite.from(schema.channelAccessTable, "ca")
.innerJoin(schema.channelsTable, "c", (ca, c) =>
SQLite.and(c.id.eq(ca.channelId), c.deletedAt.isNull()),
)
.select(($) => ({ id: $.c.id, name: $.c.name, type: $.c.type }))
.where(($) => [
$.organizationId.eq(organizationId),
$.userId.eq(payload.botUserId),
$.c.type.in_("public", "private"),
]),
)
yield* Effect.annotateCurrentSpan({ "org.bot_channels": channels.length })
return channels
.map((channel) => ({ id: channel.id, name: channel.name, private: channel.type === "private" }))
.sort((a, b) => a.name.localeCompare(b.name))
},
Effect.catchTag("DatabaseError", (error) => Effect.die(error)),
)
Loading
Loading