add experimental permission HttpApi slice (#22385)

This commit is contained in:
Kit Langton
2026-04-15 17:28:01 -04:00
committed by GitHub
parent e83b22159d
commit 250e30bc7d
21 changed files with 553 additions and 254 deletions
+78 -62
View File
@@ -7,75 +7,84 @@ import { Instance } from "@/project/instance"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
import { zod } from "@/util/effect-zod"
import { Log } from "@/util/log"
import { withStatics } from "@/util/schema"
import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import os from "os"
import z from "zod"
import { evaluate as evalRule } from "./evaluate"
import { PermissionID } from "./schema"
export namespace Permission {
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Action = Schema.Literals(["allow", "deny", "ask"])
.annotate({ identifier: "PermissionAction" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Action = Schema.Schema.Type<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export class Rule extends Schema.Class<Rule>("PermissionRule")({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}) {
static readonly zod = zod(this)
}
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export const Ruleset = Schema.mutable(Schema.Array(Rule))
.annotate({ identifier: "PermissionRuleset" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
export const Request = z
.object({
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export class Request extends Schema.Class<Request>("PermissionRequest")({
id: PermissionID,
sessionID: SessionID,
permission: Schema.String,
patterns: Schema.Array(Schema.String),
metadata: Schema.Record(Schema.String, Schema.Unknown),
always: Schema.Array(Schema.String),
tool: Schema.optional(
Schema.Struct({
messageID: MessageID,
callID: Schema.String,
}),
),
}) {
static readonly zod = zod(this)
}
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Reply = Schema.Schema.Type<typeof Reply>
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
const reply = {
reply: Reply,
message: Schema.optional(Schema.String),
}
export const ReplyBody = Schema.Struct(reply)
.annotate({ identifier: "PermissionReplyBody" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
export class Approval extends Schema.Class<Approval>("PermissionApproval")({
projectID: ProjectID,
patterns: Schema.Array(Schema.String),
}) {
static readonly zod = zod(this)
}
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Asked: BusEvent.define("permission.asked", Request.zod),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
zod(
Schema.Struct({
sessionID: SessionID,
requestID: PermissionID,
reply: Reply,
}),
),
),
}
@@ -103,20 +112,27 @@ export namespace Permission {
export type Error = DeniedError | RejectedError | CorrectedError
export const AskInput = Request.partial({ id: true }).extend({
export const AskInput = Schema.Struct({
...Request.fields,
id: Schema.optional(PermissionID),
ruleset: Ruleset,
})
.annotate({ identifier: "PermissionAskInput" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type AskInput = Schema.Schema.Type<typeof AskInput>
export const ReplyInput = z.object({
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
export const ReplyInput = Schema.Struct({
requestID: PermissionID,
...reply,
})
.annotate({ identifier: "PermissionReplyInput" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
export interface Interface {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
readonly reply: (input: ReplyInput) => Effect.Effect<void>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
}
interface PendingEntry {
@@ -163,7 +179,7 @@ export namespace Permission {
}),
)
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
const { approved, pending } = yield* InstanceState.get(state)
const { ruleset, ...request } = input
let needsAsk = false
@@ -183,10 +199,10 @@ export namespace Permission {
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
const info = Schema.decodeUnknownSync(Request)({
id,
...request,
}
})
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
@@ -200,7 +216,7 @@ export namespace Permission {
)
})
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
const { approved, pending } = yield* InstanceState.get(state)
const existing = pending.get(input.requestID)
if (!existing) return
+5 -1
View File
@@ -2,9 +2,13 @@ import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { ZodOverride } from "@/util/effect-zod"
import { Newtype } from "@/util/schema"
export class PermissionID extends Newtype<PermissionID>()("PermissionID", Schema.String) {
export class PermissionID extends Newtype<PermissionID>()(
"PermissionID",
Schema.String.annotate({ [ZodOverride]: Identifier.schema("permission") }),
) {
static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id))
}