feat(core): edit saved permissions (#56659)

This commit is contained in:
Aiden Cline 2026-10-18 21:24:38 -05:00 • committed by GitHub
parent 8c9c85c263
commit fede5ddda5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 296 additions and 18 deletions

View file

@ -1726,6 +1726,16 @@ export type PermissionSavedListOperation<E = never> = (
input?: PermissionSavedListInput,
) => Effect.Effect<PermissionSavedListOutput, E>
export type PermissionSavedUpdateInput = {
readonly id: PermissionSaved.ID
readonly action?: string | undefined
readonly resource?: string | undefined
}
export type PermissionSavedUpdateOutput = PermissionSaved.Info
export type PermissionSavedUpdateOperation<E = never> = (
input: PermissionSavedUpdateInput,
) => Effect.Effect<PermissionSavedUpdateOutput, E>
export type PermissionSavedRemoveInput = { readonly id: PermissionSaved.ID }
export type PermissionSavedRemoveOutput = void
export type PermissionSavedRemoveOperation<E = never> = (
@ -1768,7 +1778,11 @@ export type PermissionReplyOperation<E = never> = (
export interface PermissionApi<E = never> {
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
readonly saved: {
readonly list: PermissionSavedListOperation<E>
readonly update: PermissionSavedUpdateOperation<E>
readonly remove: PermissionSavedRemoveOperation<E>
}
readonly create: PermissionCreateOperation<E>
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>

View file

@ -176,6 +176,8 @@ import type {
PermissionRequestListOutput,
PermissionSavedListInput,
PermissionSavedListOutput,
PermissionSavedUpdateInput,
PermissionSavedUpdateOutput,
PermissionSavedRemoveInput,
PermissionSavedRemoveOutput,
PermissionCreateInput,
@ -1155,6 +1157,17 @@ const EndpointPermissionSavedList = (raw: RawClient["server.permission"]) => (in
),
)
const EndpointPermissionSavedUpdate = (raw: RawClient["server.permission"]) => (input: PermissionSavedUpdateInput) =>
preserveEffect<PermissionSavedUpdateOutput>()(
raw["permission.saved.update"]({
params: { id: input["id"] },
payload: { action: input["action"], resource: input["resource"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointPermissionSavedRemove = (raw: RawClient["server.permission"]) => (input: PermissionSavedRemoveInput) =>
preserveEffect<PermissionSavedRemoveOutput>()(
raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)),
@ -1205,7 +1218,11 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
request: { list: EndpointPermissionRequestList(raw) },
saved: { list: EndpointPermissionSavedList(raw), remove: EndpointPermissionSavedRemove(raw) },
saved: {
list: EndpointPermissionSavedList(raw),
update: EndpointPermissionSavedUpdate(raw),
remove: EndpointPermissionSavedRemove(raw),
},
create: EndpointPermissionCreate(raw),
list: EndpointPermissionList(raw),
get: EndpointPermissionGet(raw),

View file

@ -170,6 +170,8 @@ import type {
PermissionRequestListOutput,
PermissionSavedListInput,
PermissionSavedListOutput,
PermissionSavedUpdateInput,
PermissionSavedUpdateOutput,
PermissionSavedRemoveInput,
PermissionSavedRemoveOutput,
PermissionCreateInput,
@ -1562,6 +1564,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
update: (input: PermissionSavedUpdateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionSavedUpdateOutput }>(
{
method: "PATCH",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
body: { action: input["action"], resource: input["resource"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 409],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: PermissionSavedRemoveInput, requestOptions?: RequestOptions) =>
request<PermissionSavedRemoveOutput>(
{

View file

@ -2847,6 +2847,14 @@ export type ProjectNotFoundError = {
export const isProjectNotFoundError = (value: unknown): value is ProjectNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProjectNotFoundError"
export type PermissionSavedNotFoundError = {
readonly _tag: "PermissionSavedNotFoundError"
readonly id: string
readonly message: string
}
export const isPermissionSavedNotFoundError = (value: unknown): value is PermissionSavedNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionSavedNotFoundError"
export type PermissionNotFoundError = {
readonly _tag: "PermissionNotFoundError"
readonly requestID: string
@ -6251,6 +6259,14 @@ export type PermissionSavedListInput = { readonly projectID?: { readonly project
export type PermissionSavedListOutput = { data: Array<PermissionSavedInfo> }["data"]
export type PermissionSavedUpdateInput = {
readonly id: { readonly id: string }["id"]
readonly action?: { readonly action?: string | undefined; readonly resource?: string | undefined }["action"]
readonly resource?: { readonly action?: string | undefined; readonly resource?: string | undefined }["resource"]
}
export type PermissionSavedUpdateOutput = { data: PermissionSavedInfo }["data"]
export type PermissionSavedRemoveInput = { readonly id: { readonly id: string }["id"] }
export type PermissionSavedRemoveOutput = void

View file

@ -1,7 +1,9 @@
export * as PermissionSaved from "./saved.js"
import { eq } from "drizzle-orm"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { Cause, Context, DateTime, Effect, Layer, Schema } from "effect"
import { isSqlError } from "effect/sql/SqlError"
import type { EffectDrizzleQueryError } from "drizzle-orm/effect-core/errors"
import { Project } from "@opencode/schema/project"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
@ -26,9 +28,27 @@ export const AddInput = Schema.Struct({
}).annotate({ identifier: "PermissionSaved.AddInput" })
export type AddInput = typeof AddInput.Type
export const UpdateInput = Schema.Struct({
id: ID,
action: Schema.String.pipe(Schema.optional),
resource: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "PermissionSaved.UpdateInput" })
export type UpdateInput = typeof UpdateInput.Type
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("PermissionSaved.NotFoundError", {
id: ID,
}) {}
export class ConflictError extends Schema.TaggedError<ConflictError>()("PermissionSaved.ConflictError", {
id: ID,
action: Schema.String,
resource: Schema.String,
}) {}
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
readonly add: (input: AddInput) => Effect.Effect<void>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError | ConflictError>
readonly remove: (id: ID) => Effect.Effect<void>
}
@ -46,18 +66,7 @@ const layer = Layer.effect(
.where(input?.projectID ? eq(PermissionTable.project_id, input.projectID) : undefined)
.all()
.pipe(Effect.orDie)
return rows.map(
(row): Info => ({
id: row.id,
projectID: row.project_id,
action: row.action,
resource: row.resource,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
},
}),
)
return rows.map(info)
})
const add = Effect.fn("PermissionSaved.add")(function* (input: AddInput) {
@ -77,12 +86,58 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
})
const update = Effect.fn("PermissionSaved.update")(function* (input: UpdateInput) {
const current = yield* db
.select()
.from(PermissionTable)
.where(eq(PermissionTable.id, input.id))
.get()
.pipe(Effect.orDie)
if (!current) return yield* new NotFoundError({ id: input.id })
const action = input.action ?? current.action
const resource = input.resource ?? current.resource
// The unique index rejects an edit that duplicates another saved permission in the project.
const updated = yield* db
.update(PermissionTable)
.set({ action, resource })
.where(eq(PermissionTable.id, input.id))
.returning()
.get()
.pipe(
Effect.catchIf(isUniqueViolation, () => new ConflictError({ id: input.id, action, resource })),
Effect.catchTag("EffectDrizzleQueryError", (error) => Effect.die(error)),
)
if (!updated) return yield* new NotFoundError({ id: input.id })
return info(updated)
})
const remove = Effect.fn("PermissionSaved.remove")(function* (id: ID) {
yield* db.delete(PermissionTable).where(eq(PermissionTable.id, id)).run().pipe(Effect.orDie)
})
return Service.of({ list, add, remove })
return Service.of({ list, add, update, remove })
}),
)
// Durable Object SQLite throws a plain Error without SQLite codes, so its unique violation is only in the message.
const isUniqueViolation = (error: EffectDrizzleQueryError) => {
const sql = Cause.isCause(error.cause) ? Cause.squash(error.cause) : error.cause
if (!isSqlError(sql)) return false
return (
sql.reason._tag === "UniqueViolation" ||
(sql.reason.cause instanceof Error && sql.reason.cause.message.includes("SQLITE_CONSTRAINT_UNIQUE"))
)
}
const info = (row: typeof PermissionTable.$inferSelect): Info => ({
id: row.id,
projectID: row.project_id,
action: row.action,
resource: row.resource,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
},
})
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })

View file

@ -346,6 +346,38 @@ describe("Permission", () => {
}),
)
it.effect("edits saved approvals and enforces the edited rule", () =>
Effect.gen(function* () {
yield* setup()
const saved = yield* PermissionSaved.Service
const service = yield* Permission.Service
yield* saved.add({ projectID: Project.ID.global, action: "read", resources: ["src/*", "docs/*"] })
const src = (yield* saved.list()).find((item) => item.resource === "src/*")
expect(yield* saved.update({ id: src!.id, resource: "src/allowed.ts" })).toMatchObject({
id: src!.id,
projectID: Project.ID.global,
action: "read",
resource: "src/allowed.ts",
})
expect(
yield* service.ask(assertion({ id: Permission.ID.create("per_allowed"), resources: ["src/allowed.ts"] })),
).toMatchObject({ effect: "allow" })
expect(
yield* service.ask(assertion({ id: Permission.ID.create("per_other"), resources: ["src/other.ts"] })),
).toMatchObject({ effect: "ask" })
expect(yield* saved.update({ id: src!.id, resource: "docs/*" }).pipe(Effect.flip)).toMatchObject({
_tag: "PermissionSaved.ConflictError",
id: src!.id,
resource: "docs/*",
})
expect(
yield* saved.update({ id: PermissionSaved.ID.make("psv_missing"), action: "edit" }).pipe(Effect.flip),
).toMatchObject({ _tag: "PermissionSaved.NotFoundError" })
}),
)
for (const guard of ["configured deny", "missing Session"] as const) {
it.effect(`skips pending auto-approval after always for ${guard}`, () =>
Effect.gen(function* () {

View file

@ -215,6 +215,15 @@ export class PermissionNotFoundError extends Schema.TaggedError<PermissionNotFou
{ httpApiStatus: 404 },
) {}
export class PermissionSavedNotFoundError extends Schema.TaggedError<PermissionSavedNotFoundError>()(
"PermissionSavedNotFoundError",
{
id: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class FormNotFoundError extends Schema.TaggedError<FormNotFoundError>()(
"FormNotFoundError",
{

View file

@ -6,7 +6,13 @@ import { Project } from "@opencode/schema/project"
import { Session } from "@opencode/schema/session"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/http-api"
import { LocationNotFoundError, PermissionNotFoundError, SessionNotFoundError } from "../errors.js"
import {
ConflictError,
LocationNotFoundError,
PermissionNotFoundError,
PermissionSavedNotFoundError,
SessionNotFoundError,
} from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const makePermissionGroup = <
@ -45,6 +51,24 @@ export const makePermissionGroup = <
}),
),
)
.add(
HttpApiEndpoint.patch("permission.saved.update", "/api/permission/saved/:id", {
params: { id: PermissionSaved.ID },
payload: Schema.Struct({
action: Schema.String.pipe(Schema.optional),
resource: Schema.String.pipe(Schema.optional),
}),
success: Schema.Struct({ data: PermissionSaved.Info }),
error: [PermissionSavedNotFoundError, ConflictError],
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.saved.update",
summary: "Update saved permission",
description:
"Change the action or resource of a saved permission. Fails with a conflict when the project already has a saved permission with the same action and resource.",
}),
),
)
.add(
HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", {
params: { id: PermissionSaved.ID },

View file

@ -6,7 +6,7 @@ import { Session } from "@opencode/core/session"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/http-api"
import { Api } from "../api"
import { PermissionNotFoundError } from "@opencode/protocol/errors"
import { ConflictError, PermissionNotFoundError, PermissionSavedNotFoundError } from "@opencode/protocol/errors"
import { locationErrors, response, sessionInfo } from "../location"
import { missingSession } from "./session-error"
@ -95,6 +95,30 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
}
}),
)
.handle(
"permission.saved.update",
Effect.fn(function* (ctx) {
const saved = yield* PermissionSaved.Service
return {
data: yield* saved
.update({ id: ctx.params.id, action: ctx.payload.action, resource: ctx.payload.resource })
.pipe(
Effect.catchTags({
"PermissionSaved.NotFoundError": (error) =>
new PermissionSavedNotFoundError({
id: error.id,
message: `Saved permission not found: ${error.id}`,
}),
"PermissionSaved.ConflictError": (error) =>
new ConflictError({
resource: error.id,
message: `A saved permission already allows ${error.action} for ${error.resource}`,
}),
}),
),
}
}),
)
.handle(
"permission.saved.remove",
Effect.fn(function* (ctx) {

View file

@ -0,0 +1,73 @@
import { expect } from "bun:test"
import { PermissionSaved } from "@opencode/schema/permission-saved"
import { Session } from "@opencode/schema/session"
import { Effect, Schema } from "effect"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
const SessionResponse = Schema.Struct({ data: Schema.toEncoded(Session.Info) })
const AskResponse = Schema.Struct({ data: Schema.Struct({ id: Schema.String, effect: Schema.String }) })
const SavedList = Schema.Struct({ data: Schema.Array(Schema.toEncoded(PermissionSaved.Info)) })
const SavedResponse = Schema.Struct({ data: Schema.toEncoded(PermissionSaved.Info) })
it.live("edits saved permissions through PATCH", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make({
app: { version: "test" },
database: { path: ":memory:" },
fs: { filewatcher: false },
models: { fetch: false },
})
const request = (path: string, method: string, body?: unknown) =>
Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local${path}`, {
method,
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}),
)
return { status: response.status, body: response.status === 204 ? undefined : await response.json() }
})
const session = Schema.decodeUnknownSync(SessionResponse)(
(yield* request("/api/session", "POST", {
permissions: [{ action: "deploy", resource: "*", effect: "ask" }],
})).body,
)
// Saved permissions are only created by answering "always", so save two through the real request flow.
for (const resource of ["src/*", "docs/*"]) {
const asked = Schema.decodeUnknownSync(AskResponse)(
(yield* request(`/api/session/${session.data.id}/permission`, "POST", {
action: "deploy",
resources: [resource],
save: [resource],
})).body,
)
expect(asked.data.effect).toBe("ask")
expect(
(yield* request(`/api/session/${session.data.id}/permission/${asked.data.id}/reply`, "POST", {
decision: "always",
})).status,
).toBe(204)
}
const saved = Schema.decodeUnknownSync(SavedList)((yield* request("/api/permission/saved", "GET")).body)
const src = saved.data.find((item) => item.resource === "src/*")!
const updated = yield* request(`/api/permission/saved/${src.id}`, "PATCH", { resource: "src/allowed.ts" })
expect(updated.status).toBe(200)
expect(Schema.decodeUnknownSync(SavedResponse)(updated.body).data).toMatchObject({
id: src.id,
action: "deploy",
resource: "src/allowed.ts",
})
expect(
Schema.decodeUnknownSync(SavedList)((yield* request("/api/permission/saved", "GET")).body)
.data.map((item) => item.resource)
.toSorted(),
).toEqual(["docs/*", "src/allowed.ts"])
expect((yield* request(`/api/permission/saved/${src.id}`, "PATCH", { resource: "docs/*" })).status).toBe(409)
expect((yield* request("/api/permission/saved/psv_missing", "PATCH", { resource: "x" })).status).toBe(404)
}).pipe(Effect.scoped),
)