@checkstack/incident-common 1.5.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +149 -0
- package/package.json +7 -7
- package/src/access.ts +0 -1
- package/src/rpc-contract.test.ts +148 -0
- package/src/rpc-contract.ts +61 -1
- package/src/schemas.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,154 @@
|
|
|
1
1
|
# @checkstack/incident-common
|
|
2
2
|
|
|
3
|
+
## 1.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d2077bd: Platform-wide team-scoped access control on a unified relation-tuple store.
|
|
8
|
+
|
|
9
|
+
Admins can scope any resource to teams, and the **platform** (not each plugin)
|
|
10
|
+
enforces it. A plugin opts in declaratively by adding `instanceAccess` to a
|
|
11
|
+
procedure's contract; the auth middleware does the rest, so enforcement is
|
|
12
|
+
consistent across catalog, health checks, incidents, maintenances, SLOs,
|
|
13
|
+
automations, and the dependency map, and any third-party plugin gets it for free.
|
|
14
|
+
|
|
15
|
+
Core model:
|
|
16
|
+
|
|
17
|
+
- **Teams are optional.** A resource with no team grants behaves exactly as
|
|
18
|
+
before.
|
|
19
|
+
- **Team grants are additive and restrict who can CHANGE a resource, not who can
|
|
20
|
+
SEE it.** Granting a team `Manage` lets its members view and change the
|
|
21
|
+
resource; `Read-only` lets them view it. Either level grants access to team
|
|
22
|
+
members **even when they lack the global permission**, and granting never
|
|
23
|
+
removes read from anyone who already had it (e.g. a public status page stays
|
|
24
|
+
readable). Privacy is a separate, explicit opt-in via the **Private** toggle,
|
|
25
|
+
which removes the global read path so only the resource's teams can see it.
|
|
26
|
+
- **Ownership at creation.** Create forms expose an **Owning team** picker. A
|
|
27
|
+
non-admin can create a resource for a team they belong to that holds a
|
|
28
|
+
create-capability grant for that type; the new resource is auto-granted to that
|
|
29
|
+
team. Incidents and maintenances are **parent-gated**: anyone who can manage a
|
|
30
|
+
system may open incidents/maintenances for it, no separate grant needed.
|
|
31
|
+
- **Meaningful authorization errors.** A caller with neither the global rule nor
|
|
32
|
+
any team grant for a resource type gets a `403` with a structured body instead
|
|
33
|
+
of a silently-empty `200`. Anonymous callers on public endpoints are never
|
|
34
|
+
`403`'d, so status pages keep rendering.
|
|
35
|
+
|
|
36
|
+
Unified relation-tuple store:
|
|
37
|
+
|
|
38
|
+
- The previously separate access primitives (`resource_team_access.canRead` /
|
|
39
|
+
`.canManage`, ownership, `resource_access_settings.teamOnly`, and
|
|
40
|
+
`resource_create_grant`) are collapsed onto ONE
|
|
41
|
+
`relation_tuple(object, relation, subject)` store: "a team has
|
|
42
|
+
`viewer`/`editor`/`owner` on an object, or `creator` on a type". Privacy is an
|
|
43
|
+
explicit **`private` marker** tuple — its **presence** closes the global read
|
|
44
|
+
path (team grants only), its **absence** is the readable-by-default state, so a
|
|
45
|
+
private resource with zero grants is correctly inaccessible to everyone rather
|
|
46
|
+
than silently globalized. The access decision is a pure, unit-tested function.
|
|
47
|
+
- The auth API is generic: `writeRelation` / `removeRelation` / `setObjectPublic`
|
|
48
|
+
/ `listObjectRelations` / `listSubjectRelations` / `setCreateGrant` /
|
|
49
|
+
`listTeamCreateGrants` (user-facing) and `check` / `listAccessibleObjectIds` /
|
|
50
|
+
`hasAnyTypeGrant` / `authorizeCreate` / `setOwner` / `deleteObjectRelations`
|
|
51
|
+
(service-to-service). Migration `0008` backfills tuples from the legacy tables
|
|
52
|
+
and drops them.
|
|
53
|
+
|
|
54
|
+
Explicit per-procedure scoping:
|
|
55
|
+
|
|
56
|
+
- Access rules (`access()` / `accessPair()`) define only the rule (id, level,
|
|
57
|
+
defaults); every procedure declares its own `instanceAccess`. This removes a
|
|
58
|
+
"loaded gun" default that silently applied a shared `idParam` to any procedure
|
|
59
|
+
which forgot its own override.
|
|
60
|
+
- Modes: `idParam` (single-resource pre-check, fails **closed** if the id does
|
|
61
|
+
not resolve), `listKey` / `recordKey` (post-filter a list/record to the
|
|
62
|
+
accessible subset), `create` (authorize creation + write the owning-team
|
|
63
|
+
grant), `parentScope` (scope by read/manage access to a PARENT type,
|
|
64
|
+
cross-plugin single-hop: "you may see incidents/maintenances/SLOs/health for
|
|
65
|
+
system S iff you may see S"), and `global: true` (the honest "intentionally not
|
|
66
|
+
team-scoped" opt-out). A boot-time validator **rejects** any procedure gated on
|
|
67
|
+
a team-scopable resource type that declares no `instanceAccess`, turning the
|
|
68
|
+
previous fail-open into a boot error.
|
|
69
|
+
|
|
70
|
+
Teams administration:
|
|
71
|
+
|
|
72
|
+
- **Team managers** manage their own team's members and managers without the
|
|
73
|
+
global `auth.teams.manage` rule; creating, deleting, and granting a team access
|
|
74
|
+
remain admin-only.
|
|
75
|
+
- A **standalone Teams page** (gated on `auth.teams.read`) lets managers reach
|
|
76
|
+
team administration without the admin Auth Settings page; members are added via
|
|
77
|
+
a debounced directory picker.
|
|
78
|
+
- A **cross-plugin `ResourceResolverRegistry`** lets owning plugins register a
|
|
79
|
+
name/search resolver for their resource types, so the Teams page lists a team's
|
|
80
|
+
grants **by name** (grouped by type) and offers a resource picker — an admin can
|
|
81
|
+
change a grant's level, revoke it, or add one, without auth depending on every
|
|
82
|
+
plugin. Resolvers shipped for catalog systems, health-check configurations,
|
|
83
|
+
incidents, maintenances, SLO objectives, and automations.
|
|
84
|
+
|
|
85
|
+
Frontend:
|
|
86
|
+
|
|
87
|
+
- The resource-side editor is **"Who can change this"** (one Manage checkbox per
|
|
88
|
+
team; unticked = read-only), with an always-visible **Private** toggle
|
|
89
|
+
(disabled until a team that can Manage exists, so a resource can't be stranded).
|
|
90
|
+
- `TeamOwnershipPicker` explains _why_ there's nothing to pick (not a member of
|
|
91
|
+
any team, or none of your teams manage the selected parent) instead of a bare
|
|
92
|
+
"global resource" line.
|
|
93
|
+
- Read-only **"who can change this"** indicators on resource detail pages expand
|
|
94
|
+
to the actual people by name; bulk + per-row **Scope to team** actions in the
|
|
95
|
+
catalog systems list; and the team-access copy spells out that grants are
|
|
96
|
+
additive and that Read-only grants view (not change) even without the global
|
|
97
|
+
permission.
|
|
98
|
+
|
|
99
|
+
Security hardening:
|
|
100
|
+
|
|
101
|
+
- Child deletes in catalog (`removeSystemContact` / `removeSystemLink`) are scoped
|
|
102
|
+
to both the child id and its parent `systemId`, closing a cross-system IDOR for
|
|
103
|
+
team-scoped managers.
|
|
104
|
+
- `searchUsers` is restricted to team administrators, closing a directory/email
|
|
105
|
+
enumeration path opened by the default `auth.teams.read` rule.
|
|
106
|
+
- Grant setters reject unregistered resource types.
|
|
107
|
+
|
|
108
|
+
BREAKING CHANGES (beta; shipped as minor bumps):
|
|
109
|
+
|
|
110
|
+
- `access()` and `accessPair()` no longer accept `idParam` / `listKey` /
|
|
111
|
+
`recordKey`; move instance config to the procedure's `instanceAccess`.
|
|
112
|
+
- Boot fails if a procedure gated on a team-scopable resource type omits
|
|
113
|
+
`instanceAccess`. Declare a scoping mode or `instanceAccess: { global: true }`.
|
|
114
|
+
- The `AuthService` interface is reshaped: `check`, `listAccessibleObjectIds`,
|
|
115
|
+
`hasAnyTypeGrant`, `authorizeCreate` (returns `isPrivate`), `setOwner`
|
|
116
|
+
(`isPrivate`), and `deleteObjectRelations`. Custom `AuthService` implementations
|
|
117
|
+
and mocks must update.
|
|
118
|
+
- The auth RPC contract's per-concept resource-access endpoints are replaced by
|
|
119
|
+
the generic tuple API above; external callers of the old
|
|
120
|
+
`getResourceTeamAccess` / `setResourceTeamAccess` / `setResourceAccessSettings`
|
|
121
|
+
/ `grantResourceCreate` / etc. must move to the new procedures.
|
|
122
|
+
- Several contract inputs changed from a bare `string` to an object so the
|
|
123
|
+
middleware can resolve the resource id: catalog `deleteSystem` (`{ id }`),
|
|
124
|
+
`removeSystemContact` / `removeSystemLink` (`{ id, systemId }`); health-check
|
|
125
|
+
`deleteConfiguration` / `pauseConfiguration` / `resumeConfiguration` (`{ id }`).
|
|
126
|
+
All in-tree callers are updated.
|
|
127
|
+
- List/record endpoints that relied on returning an empty `200` to signal "no
|
|
128
|
+
access" now return a `403` for categorically-unauthorized principals.
|
|
129
|
+
- The mis-keyed bulk endpoints `getBulkIncidentsForSystems`,
|
|
130
|
+
`getBulkMaintenancesForSystems`, and `getBulkObjectivesForSystems` no longer
|
|
131
|
+
post-filter their (systemId-keyed) result; access is already gated by
|
|
132
|
+
`catalog.system` upstream.
|
|
133
|
+
- Team membership/manager mutations (`addUserToTeam`, `removeUserFromTeam`,
|
|
134
|
+
`addTeamManager`, `removeTeamManager`) now require `auth.teams.read` instead of
|
|
135
|
+
`auth.teams.manage` at the contract level (broadened to per-team managers).
|
|
136
|
+
- The `resource_team_access`, `resource_access_settings`, and
|
|
137
|
+
`resource_create_grant` tables are dropped (data backfilled into
|
|
138
|
+
`relation_tuple` by migration `0008`). A previously inconsistent "team-only with
|
|
139
|
+
zero grants" resource is now correctly inaccessible to global-access holders.
|
|
140
|
+
|
|
141
|
+
### Patch Changes
|
|
142
|
+
|
|
143
|
+
- Updated dependencies [d2077bd]
|
|
144
|
+
- Updated dependencies [9ab73c5]
|
|
145
|
+
- Updated dependencies [5c6393f]
|
|
146
|
+
- @checkstack/common@0.16.0
|
|
147
|
+
- @checkstack/catalog-common@2.4.0
|
|
148
|
+
- @checkstack/frontend-api@0.10.0
|
|
149
|
+
- @checkstack/notification-common@1.3.4
|
|
150
|
+
- @checkstack/signal-common@0.2.10
|
|
151
|
+
|
|
3
152
|
## 1.5.2
|
|
4
153
|
|
|
5
154
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/incident-common",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -9,18 +9,18 @@
|
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@checkstack/common": "0.
|
|
13
|
-
"@checkstack/catalog-common": "2.
|
|
14
|
-
"@checkstack/frontend-api": "0.
|
|
15
|
-
"@checkstack/notification-common": "1.3.
|
|
16
|
-
"@checkstack/signal-common": "0.2.
|
|
12
|
+
"@checkstack/common": "0.16.0",
|
|
13
|
+
"@checkstack/catalog-common": "2.4.0",
|
|
14
|
+
"@checkstack/frontend-api": "0.10.0",
|
|
15
|
+
"@checkstack/notification-common": "1.3.4",
|
|
16
|
+
"@checkstack/signal-common": "0.2.10",
|
|
17
17
|
"@orpc/contract": "^1.14.4",
|
|
18
18
|
"zod": "^4.2.1"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"typescript": "^5.7.2",
|
|
22
22
|
"@checkstack/tsconfig": "0.0.7",
|
|
23
|
-
"@checkstack/scripts": "0.6.
|
|
23
|
+
"@checkstack/scripts": "0.6.2"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
26
|
"typecheck": "tsgo -b",
|
package/src/access.ts
CHANGED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { incidentContract } from "./rpc-contract";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Team-scoping regression guards (G9 mis-keying).
|
|
6
|
+
*
|
|
7
|
+
* Grants for this plugin are keyed by INCIDENT id: the frontend
|
|
8
|
+
* `TeamAccessEditor` writes `resourceType="incident.incident"`,
|
|
9
|
+
* `resourceId={incident.id}`. The shared `incident` access rule, however,
|
|
10
|
+
* declares `idParam: "systemId"`. Without a per-proc override, the
|
|
11
|
+
* `autoAuthMiddleware` would look for `input.systemId` on object-scoped procs
|
|
12
|
+
* like `updateIncident({ id })`, find nothing, and SKIP the instance check —
|
|
13
|
+
* so any holder of `incident.manage` could edit any incident.
|
|
14
|
+
*
|
|
15
|
+
* These tests assert each proc carries the expected `instanceAccess` override
|
|
16
|
+
* resolving to the INCIDENT's own id (or the correct list/record key), and that
|
|
17
|
+
* the deliberately-unscoped procs carry NO override.
|
|
18
|
+
*
|
|
19
|
+
* `~orpc.meta` is the contract-procedure internals (the same accessor the
|
|
20
|
+
* catalog contract test uses); `meta.instanceAccess` is the per-proc override
|
|
21
|
+
* the middleware reads.
|
|
22
|
+
*/
|
|
23
|
+
interface InstanceAccessCreate {
|
|
24
|
+
teamIdParam?: string;
|
|
25
|
+
idField?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface InstanceAccessParentScope {
|
|
29
|
+
resourceType?: string;
|
|
30
|
+
action?: "read" | "manage";
|
|
31
|
+
idParam?: string;
|
|
32
|
+
recordKey?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface InstanceAccess {
|
|
36
|
+
global?: boolean;
|
|
37
|
+
idParam?: string;
|
|
38
|
+
listKey?: string;
|
|
39
|
+
recordKey?: string;
|
|
40
|
+
create?: InstanceAccessCreate;
|
|
41
|
+
parentScope?: InstanceAccessParentScope;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function instanceAccessFor(
|
|
45
|
+
procName: keyof typeof incidentContract,
|
|
46
|
+
): InstanceAccess | undefined {
|
|
47
|
+
const proc = incidentContract[procName] as unknown as Record<string, unknown>;
|
|
48
|
+
const orpc = proc["~orpc"] as {
|
|
49
|
+
meta?: { instanceAccess?: InstanceAccess };
|
|
50
|
+
};
|
|
51
|
+
return orpc.meta?.instanceAccess;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("incident contract object-scoped procs gate on the incident's own id", () => {
|
|
55
|
+
const idProcs: Array<{
|
|
56
|
+
proc: keyof typeof incidentContract;
|
|
57
|
+
idParam: string;
|
|
58
|
+
}> = [
|
|
59
|
+
{ proc: "getIncident", idParam: "id" },
|
|
60
|
+
{ proc: "updateIncident", idParam: "id" },
|
|
61
|
+
{ proc: "resolveIncident", idParam: "id" },
|
|
62
|
+
{ proc: "deleteIncident", idParam: "id" },
|
|
63
|
+
{ proc: "addUpdate", idParam: "incidentId" },
|
|
64
|
+
{ proc: "addLink", idParam: "incidentId" },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
for (const { proc, idParam } of idProcs) {
|
|
68
|
+
test(`${proc} carries instanceAccess idParam=${idParam} (not systemId)`, () => {
|
|
69
|
+
const access = instanceAccessFor(proc);
|
|
70
|
+
expect(access).toBeDefined();
|
|
71
|
+
expect(access?.idParam).toBe(idParam);
|
|
72
|
+
// G9 regression: must NOT inherit the rule's mis-keyed systemId.
|
|
73
|
+
expect(access?.idParam).not.toBe("systemId");
|
|
74
|
+
expect(access?.listKey).toBeUndefined();
|
|
75
|
+
expect(access?.recordKey).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("incident contract listIncidents post-filters by incident id", () => {
|
|
81
|
+
test("listIncidents carries instanceAccess listKey=incidents", () => {
|
|
82
|
+
const access = instanceAccessFor("listIncidents");
|
|
83
|
+
expect(access).toBeDefined();
|
|
84
|
+
expect(access?.listKey).toBe("incidents");
|
|
85
|
+
expect(access?.idParam).toBeUndefined();
|
|
86
|
+
expect(access?.recordKey).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("incident contract system-scoped / unresolvable procs", () => {
|
|
91
|
+
// SYSTEM-scoped reads now gate on catalog.system via parentScope (cross-plugin,
|
|
92
|
+
// single-hop): "you may see incidents for system S iff you may see S".
|
|
93
|
+
test("getIncidentsForSystem parent-scopes on catalog.system (read, idParam)", () => {
|
|
94
|
+
const ps = instanceAccessFor("getIncidentsForSystem")?.parentScope;
|
|
95
|
+
expect(ps?.resourceType).toBe("catalog.system");
|
|
96
|
+
expect(ps?.action).toBe("read");
|
|
97
|
+
expect(ps?.idParam).toBe("systemId");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("getBulkIncidentsForSystems parent-scopes on catalog.system (read, recordKey)", () => {
|
|
101
|
+
const ps = instanceAccessFor("getBulkIncidentsForSystems")?.parentScope;
|
|
102
|
+
expect(ps?.resourceType).toBe("catalog.system");
|
|
103
|
+
expect(ps?.action).toBe("read");
|
|
104
|
+
expect(ps?.recordKey).toBe("incidents");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// removeLink's input `id` is the LINK id (not the incident or system id), so
|
|
108
|
+
// no middleware pre-check can resolve a grant; it is explicitly global (the
|
|
109
|
+
// incident.manage rule still gates it).
|
|
110
|
+
test("removeLink is explicitly global (link id is not a scopable resource id)", () => {
|
|
111
|
+
expect(instanceAccessFor("removeLink")?.global).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("incident contract createIncident carries create-mode team ownership", () => {
|
|
116
|
+
/**
|
|
117
|
+
* Regression guard for create-mode wiring. The autoAuthMiddleware reads
|
|
118
|
+
* `instanceAccess.create` on a mutation proc to:
|
|
119
|
+
* 1. (pre-handler) resolve + authorise the owning team from `input[teamIdParam]`.
|
|
120
|
+
* 2. (post-handler) write a team-scoped grant keyed by `response[idField]`
|
|
121
|
+
* via `setResourceOwner`.
|
|
122
|
+
*
|
|
123
|
+
* Grants for this plugin are keyed `incident.incident / {incident.id}`, which
|
|
124
|
+
* matches the `TeamAccessEditor` usage in `IncidentEditor` (resourceType =
|
|
125
|
+
* "incident.incident", resourceId = incident.id). Hence `idField: "id"` and
|
|
126
|
+
* `teamIdParam: "teamId"` are the correct values.
|
|
127
|
+
*
|
|
128
|
+
* `createAutoIncident` is intentionally excluded: it is `userType: "service"`
|
|
129
|
+
* (system-generated incidents have no interactive team owner) and must NOT
|
|
130
|
+
* carry create-mode.
|
|
131
|
+
*/
|
|
132
|
+
test("createIncident carries instanceAccess.create with teamIdParam=teamId and idField=id", () => {
|
|
133
|
+
const access = instanceAccessFor("createIncident");
|
|
134
|
+
expect(access).toBeDefined();
|
|
135
|
+
expect(access?.create).toBeDefined();
|
|
136
|
+
expect(access?.create?.teamIdParam).toBe("teamId");
|
|
137
|
+
expect(access?.create?.idField).toBe("id");
|
|
138
|
+
// Must not carry object-scope fields — create-mode is distinct from read/update gating.
|
|
139
|
+
expect(access?.idParam).toBeUndefined();
|
|
140
|
+
expect(access?.listKey).toBeUndefined();
|
|
141
|
+
expect(access?.recordKey).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("createAutoIncident does NOT carry instanceAccess.create (service userType)", () => {
|
|
145
|
+
const access = instanceAccessFor("createAutoIncident");
|
|
146
|
+
expect(access?.create).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/rpc-contract.ts
CHANGED
|
@@ -20,6 +20,11 @@ export const incidentContract = {
|
|
|
20
20
|
operationType: "query",
|
|
21
21
|
userType: "public",
|
|
22
22
|
access: [incidentAccess.incident.read],
|
|
23
|
+
// Grants are keyed by INCIDENT id (frontend `TeamAccessEditor` writes
|
|
24
|
+
// `resourceType="incident.incident"`, `resourceId={incident.id}`). Each
|
|
25
|
+
// returned item is an `IncidentWithSystems` whose `.id` is the incident id,
|
|
26
|
+
// so the list post-filter matches grants correctly.
|
|
27
|
+
instanceAccess: { listKey: "incidents" },
|
|
23
28
|
})
|
|
24
29
|
.input(
|
|
25
30
|
z
|
|
@@ -37,6 +42,11 @@ export const incidentContract = {
|
|
|
37
42
|
operationType: "query",
|
|
38
43
|
userType: "public",
|
|
39
44
|
access: [incidentAccess.incident.read],
|
|
45
|
+
// Object-scoped: gate on the incident's OWN grant. The shared `incident`
|
|
46
|
+
// access rule declares `idParam: "systemId"`, but grants are keyed by
|
|
47
|
+
// incident id and this input carries `id` (no `systemId`), so without this
|
|
48
|
+
// override the middleware would find no id and SKIP the check (G9 bug).
|
|
49
|
+
instanceAccess: { idParam: "id" },
|
|
40
50
|
})
|
|
41
51
|
.input(z.object({ id: z.string() }))
|
|
42
52
|
.output(IncidentDetailSchema.nullable()),
|
|
@@ -46,6 +56,12 @@ export const incidentContract = {
|
|
|
46
56
|
operationType: "query",
|
|
47
57
|
userType: "public",
|
|
48
58
|
access: [incidentAccess.incident.read],
|
|
59
|
+
// SYSTEM-scoped read: input carries `systemId` and this endpoint returns
|
|
60
|
+
// incidents FOR that system. Access follows "can you read that system"
|
|
61
|
+
// (catalog.system / read), not incident-level grants.
|
|
62
|
+
instanceAccess: {
|
|
63
|
+
parentScope: { resourceType: "catalog.system", action: "read", idParam: "systemId" },
|
|
64
|
+
},
|
|
49
65
|
})
|
|
50
66
|
.input(z.object({ systemId: z.string() }))
|
|
51
67
|
.output(z.array(IncidentWithSystemsSchema)),
|
|
@@ -57,7 +73,13 @@ export const incidentContract = {
|
|
|
57
73
|
operationType: "query",
|
|
58
74
|
userType: "public",
|
|
59
75
|
access: [incidentAccess.incident.read],
|
|
60
|
-
|
|
76
|
+
// SYSTEM-scoped bulk read: output is `{ incidents: Record<systemId, ...> }`.
|
|
77
|
+
// The record is keyed by system id, so access follows per-system catalog
|
|
78
|
+
// visibility — the validator post-filters the record to only the system ids
|
|
79
|
+
// the caller can read.
|
|
80
|
+
instanceAccess: {
|
|
81
|
+
parentScope: { resourceType: "catalog.system", action: "read", recordKey: "incidents" },
|
|
82
|
+
},
|
|
61
83
|
})
|
|
62
84
|
.route({ method: "POST" })
|
|
63
85
|
.input(z.object({ systemIds: z.array(z.string()) }))
|
|
@@ -72,6 +94,22 @@ export const incidentContract = {
|
|
|
72
94
|
operationType: "mutation",
|
|
73
95
|
userType: "authenticated",
|
|
74
96
|
access: [incidentAccess.incident.manage],
|
|
97
|
+
// CREATE-mode team ownership: the autoAuthMiddleware reads `input.teamId`
|
|
98
|
+
// (optional) to resolve the owning team and, post-handler, writes a
|
|
99
|
+
// team-scoped grant keyed `incident.incident / {incident.id}` via
|
|
100
|
+
// `setResourceOwner`. The `idField: "id"` tells the middleware which field
|
|
101
|
+
// of the response carries the new resource's id. Grants are keyed by
|
|
102
|
+
// incident id, matching the `TeamAccessEditor` usage in IncidentEditor.
|
|
103
|
+
instanceAccess: {
|
|
104
|
+
create: {
|
|
105
|
+
teamIdParam: "teamId",
|
|
106
|
+
idField: "id",
|
|
107
|
+
// Anyone who can MANAGE a referenced system may create one for it; the
|
|
108
|
+
// result stays globally readable. Falls back to a per-type
|
|
109
|
+
// create-capability grant when no parent gate matches.
|
|
110
|
+
parent: { resourceType: "catalog.system", idParam: "systemIds" },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
75
113
|
})
|
|
76
114
|
.input(CreateIncidentInputSchema)
|
|
77
115
|
.output(IncidentWithSystemsSchema),
|
|
@@ -81,6 +119,9 @@ export const incidentContract = {
|
|
|
81
119
|
operationType: "mutation",
|
|
82
120
|
userType: "authenticated",
|
|
83
121
|
access: [incidentAccess.incident.manage],
|
|
122
|
+
// Object-scoped: gate on the incident's OWN grant via the `id` input.
|
|
123
|
+
// Overrides the rule's `idParam: "systemId"` (G9 bug — no `systemId` here).
|
|
124
|
+
instanceAccess: { idParam: "id" },
|
|
84
125
|
})
|
|
85
126
|
.route({ method: "PATCH" })
|
|
86
127
|
.input(UpdateIncidentInputSchema)
|
|
@@ -91,6 +132,10 @@ export const incidentContract = {
|
|
|
91
132
|
operationType: "mutation",
|
|
92
133
|
userType: "authenticated",
|
|
93
134
|
access: [incidentAccess.incident.manage],
|
|
135
|
+
// Object-scoped: gate on the incident's OWN grant. This input carries the
|
|
136
|
+
// incident id as `incidentId` (not `id`). Overrides the rule's
|
|
137
|
+
// `idParam: "systemId"` (G9 bug — no `systemId` here).
|
|
138
|
+
instanceAccess: { idParam: "incidentId" },
|
|
94
139
|
})
|
|
95
140
|
.input(AddIncidentUpdateInputSchema)
|
|
96
141
|
.output(IncidentUpdateSchema),
|
|
@@ -100,6 +145,9 @@ export const incidentContract = {
|
|
|
100
145
|
operationType: "mutation",
|
|
101
146
|
userType: "authenticated",
|
|
102
147
|
access: [incidentAccess.incident.manage],
|
|
148
|
+
// Object-scoped: gate on the incident's OWN grant via the `id` input.
|
|
149
|
+
// Overrides the rule's `idParam: "systemId"` (G9 bug — no `systemId` here).
|
|
150
|
+
instanceAccess: { idParam: "id" },
|
|
103
151
|
})
|
|
104
152
|
.input(z.object({ id: z.string(), message: z.string().optional() }))
|
|
105
153
|
.output(IncidentWithSystemsSchema),
|
|
@@ -109,6 +157,9 @@ export const incidentContract = {
|
|
|
109
157
|
operationType: "mutation",
|
|
110
158
|
userType: "authenticated",
|
|
111
159
|
access: [incidentAccess.incident.manage],
|
|
160
|
+
// Object-scoped: gate on the incident's OWN grant via the `incidentId`
|
|
161
|
+
// input. Overrides the rule's `idParam: "systemId"` (G9 bug).
|
|
162
|
+
instanceAccess: { idParam: "incidentId" },
|
|
112
163
|
})
|
|
113
164
|
.input(AddIncidentLinkInputSchema)
|
|
114
165
|
.output(IncidentLinkSchema),
|
|
@@ -118,6 +169,12 @@ export const incidentContract = {
|
|
|
118
169
|
operationType: "mutation",
|
|
119
170
|
userType: "authenticated",
|
|
120
171
|
access: [incidentAccess.incident.manage],
|
|
172
|
+
// global:true — input carries only a LINK id (`incidentLinks.id`), not an
|
|
173
|
+
// incident id or system id. The owning incident id is only resolvable
|
|
174
|
+
// server-side after the lookup, so no pre-check can scope this to a resource.
|
|
175
|
+
// `idParam: "id"` would compare a link id against incident grants and always
|
|
176
|
+
// 403. The `incident.manage` access rule still gates this endpoint globally.
|
|
177
|
+
instanceAccess: { global: true },
|
|
121
178
|
})
|
|
122
179
|
.route({ method: "DELETE" })
|
|
123
180
|
.input(z.object({ id: z.string() }))
|
|
@@ -128,6 +185,9 @@ export const incidentContract = {
|
|
|
128
185
|
operationType: "mutation",
|
|
129
186
|
userType: "authenticated",
|
|
130
187
|
access: [incidentAccess.incident.manage],
|
|
188
|
+
// Object-scoped: gate on the incident's OWN grant via the `id` input.
|
|
189
|
+
// Overrides the rule's `idParam: "systemId"` (G9 bug — no `systemId` here).
|
|
190
|
+
instanceAccess: { idParam: "id" },
|
|
131
191
|
})
|
|
132
192
|
.route({ method: "DELETE" })
|
|
133
193
|
.input(z.object({ id: z.string() }))
|
package/src/schemas.ts
CHANGED
|
@@ -96,6 +96,15 @@ export const CreateIncidentInputSchema = z.object({
|
|
|
96
96
|
.string()
|
|
97
97
|
.optional()
|
|
98
98
|
.describe("Optional initial status update message"),
|
|
99
|
+
/**
|
|
100
|
+
* Optional owning-team id. When supplied by an authenticated team member, the
|
|
101
|
+
* autoAuthMiddleware create-mode writes a team-scoped ownership grant for the
|
|
102
|
+
* newly created incident. Omitting it (or calling with global-manage) creates
|
|
103
|
+
* the incident without team scoping, exactly as before. Never written to the
|
|
104
|
+
* incident row — the router handler ignores this field before delegating to
|
|
105
|
+
* the service.
|
|
106
|
+
*/
|
|
107
|
+
teamId: z.string().optional(),
|
|
99
108
|
});
|
|
100
109
|
export type CreateIncidentInput = z.infer<typeof CreateIncidentInputSchema>;
|
|
101
110
|
|