diff --git a/app/app/(admin)/actions/page.tsx b/app/app/(admin)/actions/page.tsx index 28bfa97..19892d5 100644 --- a/app/app/(admin)/actions/page.tsx +++ b/app/app/(admin)/actions/page.tsx @@ -13,6 +13,7 @@ export default async function ActionsPage({
+
); } diff --git a/app/components/crud/crud-table.tsx b/app/components/crud/crud-table.tsx index 091c98d..c5ca9a6 100644 --- a/app/components/crud/crud-table.tsx +++ b/app/components/crud/crud-table.tsx @@ -447,6 +447,20 @@ function TableRowActions({ Delete + ) : resourceKey === "action-assignment-rules" ? ( + ) : resourceKey === "policies" ? ( ) : null} + {resource.key === "action-assignment-rules" ? ( + onOpenChange(false)} + onSaved={onRefresh} + /> + ) : null} {resource.key === "policies" ? ( onOpenChange(false)} @@ -125,6 +132,7 @@ function usesFallbackCreateForm(resourceKey: string) { "permission-blocks", "capability-actions", "capabilities", + "action-assignment-rules", "policies", ].includes(resourceKey); } diff --git a/app/components/guardrails/action-assignment-rule-create-form.test.tsx b/app/components/guardrails/action-assignment-rule-create-form.test.tsx new file mode 100644 index 0000000..d82d33f --- /dev/null +++ b/app/components/guardrails/action-assignment-rule-create-form.test.tsx @@ -0,0 +1,151 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TenantContext } from "@/components/app-shell/tenant-provider"; +import { ActionAssignmentRuleCreateForm } from "@/components/guardrails/action-assignment-rule-create-form"; +import { GLOBAL_TENANT, type TenantSelection } from "@/lib/tenant/context"; + +const mocks = vi.hoisted(() => ({ + graphqlClient: vi.fn(), +})); + +vi.mock("@/lib/graphql/client", () => ({ + graphqlClient: mocks.graphqlClient, +})); + +function renderForm(selection: TenantSelection) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + + + , + ); +} + +async function selectOption(label: RegExp, option: string) { + const user = userEvent.setup(); + await user.click(screen.getByLabelText(label)); + await user.click(await screen.findByRole("option", { name: option })); +} + +function createMutationVariables() { + const call = mocks.graphqlClient.mock.calls.find(([arg]) => + String(arg.query).includes("mutation CreateActionAssignmentRule"), + ); + return call?.[0].variables; +} + +describe("ActionAssignmentRuleCreateForm", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + mocks.graphqlClient.mockReset(); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; + } + if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = () => {}; + } + if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => {}; + } + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; + } + mocks.graphqlClient.mockImplementation(({ query }) => { + if (String(query).includes("ActionAssignmentRuleFormActions")) { + return Promise.resolve({ + actions: { + items: [{ id: "action-manage", name: "manage", description: null }], + }, + }); + } + if (String(query).includes("CreateActionAssignmentRule")) { + return Promise.resolve({ + createActionAssignmentRule: { + id: "rule-1", + tenantId: null, + entityKind: "device", + actionName: "manage", + objectKind: "resource", + objectType: null, + decision: "allow", + isAbsolute: false, + createdAt: "2026-01-01T00:00:00Z", + }, + }); + } + return Promise.resolve({}); + }); + }); + + it("does not expose require_override in the create decision selector", async () => { + renderForm({ id: GLOBAL_TENANT, name: "Global" }); + + const user = userEvent.setup(); + await user.click(screen.getByLabelText(/decision/i)); + + expect(screen.getByRole("option", { name: "allow" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "deny" })).toBeInTheDocument(); + expect(screen.queryByText("require_override")).not.toBeInTheDocument(); + }); + + it("submits global guardrails with a null tenantId", async () => { + renderForm({ id: GLOBAL_TENANT, name: "Global" }); + + const user = userEvent.setup(); + await selectOption(/action_name/i, "manage"); + await user.click(screen.getByRole("button", { name: "Create guardrail" })); + + await waitFor(() => { + expect(createMutationVariables()).toMatchObject({ + input: { + tenantId: null, + entityKind: "device", + actionName: "manage", + objectKind: "resource", + decision: "allow", + isAbsolute: false, + }, + }); + }); + }); + + it("submits tenant guardrails with the selected tenantId and deny decision", async () => { + renderForm({ id: "tenant-1", name: "Tenant 1" }); + + const user = userEvent.setup(); + await selectOption(/action_name/i, "manage"); + await user.click(screen.getByRole("button", { name: "Create guardrail" })); + + await waitFor(() => { + expect(createMutationVariables()).toMatchObject({ + input: { + tenantId: "tenant-1", + actionName: "manage", + decision: "deny", + isAbsolute: false, + }, + }); + }); + }); +}); diff --git a/app/components/guardrails/action-assignment-rule-create-form.tsx b/app/components/guardrails/action-assignment-rule-create-form.tsx new file mode 100644 index 0000000..91ec580 --- /dev/null +++ b/app/components/guardrails/action-assignment-rule-create-form.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { useTenant } from "@/components/app-shell/tenant-provider"; +import { RequiredFormLabel } from "@/components/forms/required-form-label"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { graphqlClient } from "@/lib/graphql/client"; +import { tenantQueryValue } from "@/lib/tenant/context"; + +const ACTIONS_QUERY = ` + query ActionAssignmentRuleFormActions { + actions(limit: 500, offset: 0) { items { id name description } } + } +`; + +const CREATE_RULE_MUTATION = ` + mutation CreateActionAssignmentRule($input: CreateActionAssignmentRuleInput!) { + createActionAssignmentRule(input: $input) { + id + tenantId + entityKind + actionName + objectKind + objectType + decision + isAbsolute + createdAt + } + } +`; + +const ENTITY_KINDS = [ + "human", + "device", + "service", + "workload", + "application", +] as const; + +const OBJECT_KINDS = [ + "entity", + "resource", + "group", + "tenant", + "role", + "policy", + "credential", + "audit_log", + "signing_key", +] as const; + +const schema = z.object({ + entityKind: z.enum(ENTITY_KINDS), + actionName: z.string().min(1, "action_name is required."), + objectKind: z.enum(OBJECT_KINDS), + objectType: z.string().trim(), + decision: z.enum(["allow", "deny"]), + isAbsolute: z.boolean(), +}); + +type Values = z.infer; +type ActionOption = { id: string; name: string; description?: string | null }; + +export function ActionAssignmentRuleCreateForm({ + onCancel, + onSaved, +}: { + onCancel: () => void; + onSaved: () => void; +}) { + const { selection } = useTenant(); + const tenantId = tenantQueryValue(selection); + const actionsQuery = useQuery({ + queryKey: ["action-assignment-rule-form-actions"], + queryFn: ({ signal }) => + graphqlClient<{ actions: { items: ActionOption[] } }>({ + query: ACTIONS_QUERY, + signal, + }), + staleTime: 60_000, + }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + entityKind: "device", + actionName: "", + objectKind: "resource", + objectType: "", + decision: tenantId ? "deny" : "allow", + isAbsolute: false, + }, + }); + + const save = useMutation({ + mutationFn: (values: Values) => + graphqlClient({ + query: CREATE_RULE_MUTATION, + variables: { + input: { + tenantId, + entityKind: values.entityKind, + actionName: values.actionName, + objectKind: values.objectKind, + objectType: values.objectType || null, + decision: tenantId ? "deny" : values.decision, + isAbsolute: tenantId ? false : values.isAbsolute, + }, + }, + }), + onSuccess: () => { + toast.success("Assignment guardrail created"); + onSaved(); + }, + onError: (err) => toast.error(err.message), + }); + + const actions = actionsQuery.data?.actions.items ?? []; + + React.useEffect(() => { + if (!tenantId) return; + form.setValue("decision", "deny"); + form.setValue("isAbsolute", false); + }, [form, tenantId]); + + return ( +
+ save.mutate(values))} + > + ( + + entity_kind + + + + )} + /> + + ( + + action_name + + + + )} + /> + + ( + + object_kind + + + + )} + /> + + ( + + object_type + + + + + Leave empty to match every sub-kind for the selected object + kind. + + + + )} + /> + + ( + + decision + + + + )} + /> + + {!tenantId ? ( + ( + +
+ is_absolute + + Absolute global rules cannot be overridden by tenants. + +
+ + + +
+ )} + /> + ) : null} + +
+ + +
+ + + ); +} diff --git a/app/lib/crud/resources.ts b/app/lib/crud/resources.ts index ec4319b..e547ddd 100644 --- a/app/lib/crud/resources.ts +++ b/app/lib/crud/resources.ts @@ -375,6 +375,94 @@ export const crudResources: CrudResource[] = [ "Action applicability rows are replaced by deleting and creating rows.", }, }, + { + key: "action-assignment-rules", + title: "Assignment Guardrails", + route: "/actions", + description: + "Rows from action_assignment_rules: assignment-time allow and deny guardrails by entity kind, action, and protected object.", + icon: SlidersHorizontal, + queryName: "actionAssignmentRules", + tenantFilter: true, + listQuery: `query ActionAssignmentRules($tenantId: ID, $entityKind: EntityKind, $actionName: String, $objectKind: String, $decision: ActionAssignmentRuleDecision, $limit: Int = 50, $offset: Int = 0) { actionAssignmentRules(tenantId: $tenantId, entityKind: $entityKind, actionName: $actionName, objectKind: $objectKind, decision: $decision, limit: $limit, offset: $offset) { total items { id tenantId entityKind actionName objectKind objectType decision isAbsolute createdAt } } }`, + deleteMutation: `mutation DeleteActionAssignmentRule($id: ID!) { deleteActionAssignmentRule(id: $id) }`, + deleteIdField: "id", + filters: [ + { + key: "entityKind", + variable: "entityKind", + label: "entity_kind", + type: "select", + options: [ + { label: "Human", value: "human" }, + { label: "Device", value: "device" }, + { label: "Service", value: "service" }, + { label: "Workload", value: "workload" }, + { label: "Application", value: "application" }, + ], + }, + { + key: "actionName", + variable: "actionName", + label: "action_name", + type: "text", + placeholder: "Filter action...", + }, + { + key: "objectKind", + variable: "objectKind", + label: "object_kind", + type: "select", + options: [ + { label: "Entity", value: "entity" }, + { label: "Resource", value: "resource" }, + { label: "Object group", value: "group" }, + { label: "Tenant", value: "tenant" }, + { label: "Role", value: "role" }, + { label: "Policy", value: "policy" }, + { label: "Credential", value: "credential" }, + { label: "Audit log", value: "audit_log" }, + { label: "Signing key", value: "signing_key" }, + ], + }, + { + key: "decision", + variable: "decision", + label: "decision", + type: "select", + options: [ + { label: "Allow", value: "allow" }, + { label: "Deny", value: "deny" }, + { label: "Require override", value: "require_override" }, + ], + }, + ], + columns: [ + { key: "tenantId", label: "Scope", priority: "medium" }, + { key: "entityKind", label: "Entity kind", priority: "high" }, + { key: "actionName", label: "Action", priority: "high" }, + { key: "objectKind", label: "Object kind", priority: "high" }, + { key: "objectType", label: "Object type", priority: "medium" }, + { key: "decision", label: "Decision", priority: "high" }, + { key: "isAbsolute", label: "Absolute", priority: "medium" }, + { key: "createdAt", label: "Created", priority: "low" }, + ], + sampleRows: [ + { + id: "assignment-rule-device-manage-resource", + entityKind: "device", + actionName: "manage", + objectKind: "resource", + objectType: null, + decision: "deny", + isAbsolute: true, + }, + ], + missing: { + update: + "Assignment guardrail rules are replaced by deleting and creating rows.", + }, + }, { key: "policies", title: "Direct Policies", diff --git a/app/tests/e2e/admin-shell.spec.ts b/app/tests/e2e/admin-shell.spec.ts index a251e99..4f01814 100644 --- a/app/tests/e2e/admin-shell.spec.ts +++ b/app/tests/e2e/admin-shell.spec.ts @@ -33,6 +33,7 @@ test("authenticated admin can reach core workflows", async ({ page }) => { ["/resources", "Resources"], ["/roles", "Roles"], ["/actions", "Actions"], + ["/actions", "Assignment Guardrails"], ["/authz", "Authorization debugger"], ["/audit", "Audit Logs"], ["/endpoints", "API Endpoints"], diff --git a/docs/content/docs/access-control/assignment-guardrails.mdx b/docs/content/docs/access-control/assignment-guardrails.mdx new file mode 100644 index 0000000..1def480 --- /dev/null +++ b/docs/content/docs/access-control/assignment-guardrails.mdx @@ -0,0 +1,170 @@ +--- +title: Assignment Guardrails +description: Assignment-time rules that prevent unsafe access state from being created. +--- + +Assignment guardrails decide whether Atom should allow a permission to be +assigned in the first place. + +Runtime authorization asks: + +```text +Can subject S perform action A on object O right now? +``` + +Assignment guardrails ask: + +```text +Is it safe to create this role link, direct policy, permission block link, or group membership? +``` + +This keeps the PDP generic. `/authz/check` still evaluates the current access +state with deny-overrides-allow. Guardrails run earlier, when access state is +created or changed, so unsafe grants do not enter the system unnoticed. + +## Rule Shape + +Guardrail rules live in `action_assignment_rules`. + +| Field | Meaning | +|---|---| +| `tenantId` | `null` for a global rule, or a tenant ID for a tenant-specific rule. | +| `entityKind` | Kind of subject receiving access, such as `human`, `device`, or `service`. | +| `actionName` | Existing action name, such as `publish`, `manage`, or `policy.manage`. | +| `objectKind` | Protected object kind, such as `resource`, `entity`, `role`, `policy`, or `signing_key`. | +| `objectType` | Optional namespaced subtype, such as `resource:channel` or `entity:device`. | +| `decision` | `allow`, `deny`, or existing `require_override` rows. New v1 rules can only be `allow` or `deny`. | +| `isAbsolute` | Global absolute rules cannot be overridden by tenant-specific rules. | +| `createdAt` | Rule creation timestamp. | + +`objectType` must be namespaced when present. Use values like +`resource:channel`, not only `channel`. + +## Decisions + +| Decision | Meaning | +|---|---| +| `allow` | The assignment can be created if other validation also passes. | +| `deny` | The assignment is rejected. | +| `require_override` | Existing rows can be displayed, but new override creation is deferred in v1. | + +Tenant-scoped v1 rules can only be stricter `deny` rules. They always use +`isAbsolute = false`. + +Global platform rules may use `allow` or `deny`. Only global rules may be +absolute. + +## Where Guardrails Apply + +Atom validates guardrails when access could be created through: + +- role assignments; +- composite role assignments; +- direct policies; +- linking permission blocks to roles; +- group membership changes that would make a subject inherit access. + +This matters because access can be hidden behind a role, a direct policy, or a +principal group. Guardrails check the resulting assignment effect, not just the +API object being written. + +## Examples + +| Entity kind | Action | Object kind | Object type | Decision | +|---|---|---|---|---| +| `device` | `publish` | `resource` | `resource:channel` | `allow` | +| `device` | `subscribe` | `resource` | `resource:channel` | `allow` | +| `device` | `manage` | `resource` | `resource:channel` | `deny` | +| `device` | `delete` | `resource` | `resource:channel` | `deny` | +| `human` | `manage` | `resource` | `resource:channel` | `allow` | +| `service` | `policy.manage` | `policy` | `null` | `allow` | + +These examples do not grant access by themselves. They only say whether Atom may +create access records that would grant those actions. A matching permission +block and assignment are still required for runtime authorization to allow a +request. + +## Authorization To Manage Rules + +Listing global guardrails requires the same policy-read access used by policy +and action administration views. + +Creating or deleting global guardrails requires platform `policy.manage`. + +Creating or deleting tenant guardrails requires tenant-scoped `policy.manage` +for the selected tenant. Tenant admins cannot create global rules and cannot +create tenant `allow` or absolute rules in v1. + +## GraphQL + +List rules: + +```graphql +query AssignmentGuardrails($tenantId: ID, $limit: Int = 50, $offset: Int = 0) { + actionAssignmentRules(tenantId: $tenantId, limit: $limit, offset: $offset) { + total + items { + id + tenantId + entityKind + actionName + objectKind + objectType + decision + isAbsolute + createdAt + } + } +} +``` + +Create a global rule: + +```graphql +mutation { + createActionAssignmentRule(input: { + tenantId: null + entityKind: device + actionName: "manage" + objectKind: "resource" + objectType: "resource:channel" + decision: deny + isAbsolute: true + }) { + id + decision + isAbsolute + } +} +``` + +Delete a rule: + +```graphql +mutation { + deleteActionAssignmentRule(id: "00000000-0000-0000-0000-000000000000") +} +``` + +The output enum `ActionAssignmentRuleDecision` includes `allow`, `deny`, and +`require_override`. The create-input enum `CreateActionAssignmentRuleDecision` +includes only `allow` and `deny`. + +## Atom UI + +Open the Atom UI and go to `/actions`. The Actions page has three workspaces: + +- Actions; +- Action Applicability; +- Assignment Guardrails. + +The Assignment Guardrails table shows scope, tenant, entity kind, action, object +kind, object type, decision, absolute, and created timestamp. It supports +filters for entity kind, action name, object kind, and decision. + +The create form uses the current tenant switcher: + +- in global context, it sends `tenantId: null`; +- in tenant context, it sends the selected tenant ID; +- tenant context only allows `deny` and forces `isAbsolute: false`; +- `require_override` is not shown in the create selector in v1. diff --git a/docs/content/docs/access-control/index.mdx b/docs/content/docs/access-control/index.mdx index a7ba44b..a554b12 100644 --- a/docs/content/docs/access-control/index.mdx +++ b/docs/content/docs/access-control/index.mdx @@ -32,6 +32,7 @@ Permission Block is the only place where scope and actions are defined. | Action | Operation name such as `read`, `publish`, `subscribe`, or `manage`. | | Action Applicability | Valid action/object pair, such as `publish` on `resource:channel`. | | Permission Block | Scope, actions, effect, and optional conditions. | +| Assignment Guardrail | Assignment-time rule that allows or blocks an action from being granted to an entity kind. | | Role | Friendly name for a set of permission blocks. | | Role Assignment | Gives a role to an entity or principal group. | | Direct Policy | Gives one permission block directly to an entity or principal group. | @@ -46,6 +47,7 @@ Permission Block is the only place where scope and actions are defined. - Scope and actions live in permission blocks. - Role assignments do not define scope. - Direct policies do not redefine scope. +- Assignment guardrails prevent unsafe access state from being created. - Groups do not grant access by themselves; assignments and permission blocks do. ## Actions And Applicability @@ -76,11 +78,26 @@ Examples: Invalid pairs, such as `publish` on an entity or `execute` on a channel, are rejected before permission blocks are evaluated. +## Assignment Guardrails + +Assignment Guardrails validate whether it is safe to create access state, such +as a role assignment, direct policy, role-to-permission-block link, or group +membership that would grant inherited access. + +They are different from runtime authorization. `/authz/check` answers whether +the current subject may perform the current action on the current object. +Assignment Guardrails run when access is being assigned or changed. + +For example, Atom can allow devices to receive `publish` on +`resource:channel`, while denying devices from receiving `manage` on +`resource:channel`. + ## Where To Go Next + diff --git a/docs/content/docs/access-control/meta.json b/docs/content/docs/access-control/meta.json index bab100e..7e65ed7 100644 --- a/docs/content/docs/access-control/meta.json +++ b/docs/content/docs/access-control/meta.json @@ -1,4 +1,4 @@ { "title": "Access Control", - "pages": ["index", "rbac", "abac", "policies", "evaluation"] + "pages": ["index", "rbac", "abac", "policies", "assignment-guardrails", "evaluation"] } diff --git a/docs/content/docs/architecture/data-model.mdx b/docs/content/docs/architecture/data-model.mdx index 9d88a4b..7b24fdb 100644 --- a/docs/content/docs/architecture/data-model.mdx +++ b/docs/content/docs/architecture/data-model.mdx @@ -17,6 +17,7 @@ Atom stores security state in Postgres. All primary keys are UUIDs. Most objects role[Role] block[Permission Block] action[Action] + guardrail[Assignment Guardrail] assignment[Role Assignment] direct[Direct Policy] audit[Audit Log] @@ -36,13 +37,16 @@ Atom stores security state in Postgres. All primary keys are UUIDs. Most objects entity --> direct principalGroup --> direct block --> action + guardrail --> action + guardrail --> assignment + guardrail --> direct objectGroup --> block resource --> block credential --> certState entity --> audit `} /> -**What this means:** entities prove identity with credentials. Roles and direct policies connect subjects to permission blocks. Permission blocks say where actions apply. Audit logs record what happened. +**What this means:** entities prove identity with credentials. Roles and direct policies connect subjects to permission blocks. Permission blocks say where actions apply. Assignment guardrails prevent unsafe access state from being created. Audit logs record what happened. ## Core Tables @@ -58,6 +62,7 @@ Atom stores security state in Postgres. All primary keys are UUIDs. Most objects | `roles` | Friendly names for sets of permission blocks. | | `actions` | Operation names such as `read`, `publish`, `manage`, and `authz.check`. | | `action_applicability` | Defines which actions are valid for which protected object kinds/types. | +| `action_assignment_rules` | Defines which entity kinds may be assigned which actions on protected object kinds/types. | | `permission_blocks` | The access rule: scope, effect, optional conditions, and object boundary. | | `permission_block_actions` | Links actions to a permission block. | | `role_permission_blocks` | Links permission blocks to roles. | @@ -84,6 +89,7 @@ The authorization model is split deliberately: - `actions` name the operation; - `action_applicability` says where the operation is valid, but does not grant access; +- `action_assignment_rules` say whether access may be assigned to an entity kind; - `permission_blocks` define the actual rule; - `roles` bundle permission blocks; - `role_assignments` give roles to subjects; diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index e51332f..30f0b16 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -46,7 +46,7 @@ Atom makes those decisions consistent, visible, and reusable. |---|---| | **Identity** | CRUD for any principal type — humans, devices, services, workloads, applications, and AI agents represented through those entity kinds. All are first-class _entities_. | | **Authentication** | Password login returning a JWT, long-lived API keys, session management with revocation. | -| **Authorization** | Actions, Action Applicability, Permission Blocks, Roles, Role Assignments, Direct Policies, and ABAC guardrails evaluated at runtime. | +| **Authorization** | Actions, Action Applicability, Assignment Guardrails, Permission Blocks, Roles, Role Assignments, Direct Policies, and ABAC conditions. | | **Grouping** | Principal Groups define who receives roles; Object Groups define where access applies. | | **Ownership** | Parent–child relationships between entities. | | **Multi-tenancy** | First-class tenants. Magistrala domains map directly to Atom tenants. | @@ -95,7 +95,7 @@ This docs site is an operator/developer guide. The product source of truth lives ## GraphQL -Atom exposes GraphQL at `POST /graphql`. GraphQL uses the same Bearer token authentication as Auth/OIDC REST endpoints. The schema covers health, login/logout/session lookup, tenants, profiles, profile versions, entities, resources, groups, credentials, ownerships, roles, actions, permission blocks, role assignments, Direct Policies, authz checks, audit logs, and profile-driven entity creation. Non-GraphQL public endpoints are intentionally limited to Auth/OIDC, health, JWKS, public PKI artifacts, and custom endpoint execution. +Atom exposes GraphQL at `POST /graphql`. GraphQL uses the same Bearer token authentication as Auth/OIDC REST endpoints. The schema covers health, login/logout/session lookup, tenants, profiles, profile versions, entities, resources, groups, credentials, ownerships, roles, actions, Action Applicability, Assignment Guardrails, permission blocks, role assignments, Direct Policies, authz checks, audit logs, and profile-driven entity creation. Non-GraphQL public endpoints are intentionally limited to Auth/OIDC, health, JWKS, public PKI artifacts, and custom endpoint execution. The Atom Next UI is a separate optional frontend. In Docker Compose it is enabled with the `atom-ui` profile and uses Atom GraphQL through `ATOM_GRAPHQL_URL`. @@ -200,6 +200,6 @@ Generic application mapping uses Atom operations directly: a domain-like app cal - + diff --git a/docs/content/docs/simple-words.mdx b/docs/content/docs/simple-words.mdx index 5086bd9..4ef434d 100644 --- a/docs/content/docs/simple-words.mdx +++ b/docs/content/docs/simple-words.mdx @@ -41,6 +41,7 @@ Can this certificate still identify this client? | Action | One operation someone wants to do | `read`, `publish`, `subscribe`, `manage` | | Action Applicability | Whether an action makes sense for a kind of object | `publish` is valid for channels | | Permission Block | The actual access rule: where, what actions, allow or deny | Channels in Plant-A can be published to | +| Assignment Guardrail | A safety rule for what access may be assigned | Devices may publish to channels but may not manage them | | Role | A friendly name for a set of permission blocks | `Plant-A Publisher` | | Role Assignment | Gives a role to an entity or group | Give `Plant-A Publisher` to `meter-001` | | Direct Policy | Gives one permission block directly without a role | Allow one service to publish to one channel | @@ -87,6 +88,10 @@ Action Applicability is only a validity check. It can say "`publish` is a valid action for channels," but it does not give anyone permission. A role assignment or direct policy must still point to a permission block that grants the action. +Assignment Guardrails are another safety check. They do not answer whether a +live request is allowed. They answer whether Atom should let an administrator +create access state, such as giving a device a role that can `manage` channels. + ## Certificates In One Sentence Certificates are another kind of credential. diff --git a/migrations/003_action_assignment_rule_uniqueness.sql b/migrations/003_action_assignment_rule_uniqueness.sql new file mode 100644 index 0000000..1d9015e --- /dev/null +++ b/migrations/003_action_assignment_rule_uniqueness.sql @@ -0,0 +1,12 @@ +-- Prevent duplicate equal-precedence guardrail rules. +-- +-- tenant_id and object_type are nullable. A plain UNIQUE constraint would treat +-- NULL values as distinct, so use normalized expression keys. +CREATE UNIQUE INDEX idx_aar_unique_rule + ON action_assignment_rules ( + COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid), + entity_kind, + action_name, + object_kind, + COALESCE(object_type, '') + ); diff --git a/src/authz/repo.rs b/src/authz/repo.rs index 4a51d47..a225d6b 100644 --- a/src/authz/repo.rs +++ b/src/authz/repo.rs @@ -21,13 +21,20 @@ use crate::{ SubjectRoleAssignment, SubjectRoleAssignmentList, SubjectRoleAssignmentsQuery, UnprotectedResourceItem, UnprotectedResourcesQuery, UnprotectedResourcesResponse, }, + action_assignment_rule::{ + ActionAssignmentRule, ActionAssignmentRuleList, CreateActionAssignmentRule, + ListActionAssignmentRules, + }, capability::{ Capability, CapabilityApplicability, CapabilityApplicabilityEntry, CapabilityApplicabilityInput, CapabilityApplicabilityList, CreateCapability, ListCapabilities, }, entity::Entity, - enums::{CredentialKind, Effect, GrantKind, ScopeKind, SubjectKind}, + enums::{ + ActionAssignmentDecision, CredentialKind, Effect, GrantKind, ObjectKind, ScopeKind, + SubjectKind, + }, group::Group, policy::{ CreateDirectPolicy, CreatePermissionBlock, CreatePolicyBinding, CreateRoleAssignment, @@ -621,6 +628,8 @@ pub async fn replace_role_permission_block_links( )); } } + crate::guardrails::validate_role_permission_block_links(pool, role_id, &unique_block_ids) + .await?; let mut tx = pool.begin().await.map_err(db_err)?; sqlx::query("DELETE FROM role_permission_blocks WHERE role_id = $1") @@ -2239,6 +2248,187 @@ pub async fn list_capability_applicability( Ok(CapabilityApplicabilityList { items, total }) } +pub async fn get_action_assignment_rule( + pool: &PgPool, + id: Uuid, +) -> Result { + sqlx::query_as::<_, ActionAssignmentRule>( + r#"SELECT id, tenant_id, entity_kind, action_name, object_kind, object_type, + decision, is_absolute, created_at + FROM action_assignment_rules + WHERE id = $1"#, + ) + .bind(id) + .fetch_one(pool) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + AppError::not_found(format!("action assignment rule {id} not found")) + } + other => AppError::Database(other), + }) +} + +pub async fn list_action_assignment_rules( + pool: &PgPool, + params: ListActionAssignmentRules, +) -> Result { + let limit = params.limit.clamp(1, 100); + let offset = params.offset.max(0); + let action_name = normalize_optional_text(params.action_name); + let action_pattern = action_name.as_ref().map(|value| format!("%{value}%")); + let object_type = normalize_optional_text(params.object_type); + + let items = sqlx::query_as::<_, ActionAssignmentRule>( + r#"SELECT id, tenant_id, entity_kind, action_name, object_kind, object_type, + decision, is_absolute, created_at + FROM action_assignment_rules + WHERE tenant_id IS NOT DISTINCT FROM $3 + AND ($4::text IS NULL OR entity_kind = $4) + AND ($5::text IS NULL OR action_name ILIKE $5) + AND ($6::text IS NULL OR object_kind = $6) + AND ($7::text IS NULL OR object_type = $7) + AND ($8::text IS NULL OR decision = $8) + ORDER BY entity_kind, action_name, object_kind, object_type NULLS FIRST, decision + LIMIT $1 OFFSET $2"#, + ) + .bind(limit) + .bind(offset) + .bind(params.tenant_id) + .bind(¶ms.entity_kind) + .bind(&action_pattern) + .bind(params.object_kind) + .bind(&object_type) + .bind(params.decision) + .fetch_all(pool) + .await + .map_err(db_err)?; + + let total = sqlx::query_scalar( + r#"SELECT COUNT(*) + FROM action_assignment_rules + WHERE tenant_id IS NOT DISTINCT FROM $1 + AND ($2::text IS NULL OR entity_kind = $2) + AND ($3::text IS NULL OR action_name ILIKE $3) + AND ($4::text IS NULL OR object_kind = $4) + AND ($5::text IS NULL OR object_type = $5) + AND ($6::text IS NULL OR decision = $6)"#, + ) + .bind(params.tenant_id) + .bind(¶ms.entity_kind) + .bind(&action_pattern) + .bind(params.object_kind) + .bind(&object_type) + .bind(params.decision) + .fetch_one(pool) + .await + .map_err(db_err)?; + + Ok(ActionAssignmentRuleList { items, total }) +} + +pub async fn create_action_assignment_rule( + pool: &PgPool, + req: CreateActionAssignmentRule, +) -> Result { + let action_name = req.action_name.trim().to_string(); + if action_name.is_empty() { + return Err(AppError::bad_request("actionName is required")); + } + if req.decision == ActionAssignmentDecision::RequireOverride { + return Err(AppError::bad_request( + "require_override guardrail creation is not available in v1", + )); + } + if req.tenant_id.is_some() && req.decision != ActionAssignmentDecision::Deny { + return Err(AppError::bad_request( + "tenant-specific guardrail rules can only deny in v1", + )); + } + if req.tenant_id.is_some() && req.is_absolute { + return Err(AppError::bad_request( + "tenant-specific guardrail rules cannot be absolute", + )); + } + + let object_type = normalize_optional_text(req.object_type); + validate_rule_object_type(req.object_kind, object_type.as_deref())?; + + let action_exists: bool = + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM actions WHERE name = $1)") + .bind(&action_name) + .fetch_one(pool) + .await + .map_err(db_err)?; + if !action_exists { + return Err(AppError::bad_request(format!( + "actionName references unknown action {action_name}" + ))); + } + + let duplicate: bool = sqlx::query_scalar( + r#"SELECT EXISTS ( + SELECT 1 + FROM action_assignment_rules + WHERE tenant_id IS NOT DISTINCT FROM $1 + AND entity_kind = $2 + AND action_name = $3 + AND object_kind = $4 + AND object_type IS NOT DISTINCT FROM $5 + )"#, + ) + .bind(req.tenant_id) + .bind(&req.entity_kind) + .bind(&action_name) + .bind(req.object_kind) + .bind(&object_type) + .fetch_one(pool) + .await + .map_err(db_err)?; + if duplicate { + return Err(AppError::conflict("action assignment rule already exists")); + } + + sqlx::query_as::<_, ActionAssignmentRule>( + r#"INSERT INTO action_assignment_rules + (tenant_id, entity_kind, action_name, object_kind, object_type, decision, is_absolute) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, tenant_id, entity_kind, action_name, object_kind, object_type, + decision, is_absolute, created_at"#, + ) + .bind(req.tenant_id) + .bind(req.entity_kind) + .bind(action_name) + .bind(req.object_kind) + .bind(object_type) + .bind(req.decision) + .bind(req.is_absolute) + .fetch_one(pool) + .await + .map_err(db_err) +} + +pub async fn delete_action_assignment_rule( + pool: &PgPool, + id: Uuid, +) -> Result { + sqlx::query_as::<_, ActionAssignmentRule>( + r#"DELETE FROM action_assignment_rules + WHERE id = $1 + RETURNING id, tenant_id, entity_kind, action_name, object_kind, object_type, + decision, is_absolute, created_at"#, + ) + .bind(id) + .fetch_one(pool) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + AppError::not_found(format!("action assignment rule {id} not found")) + } + other => AppError::Database(other), + }) +} + pub async fn add_capability_applicability( pool: &PgPool, capability_id: Uuid, @@ -2322,6 +2512,29 @@ pub async fn remove_capability_applicability( Ok(()) } +fn normalize_optional_text(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn validate_rule_object_type( + object_kind: ObjectKind, + object_type: Option<&str>, +) -> Result<(), AppError> { + if let Some(object_type) = object_type { + let (prefix, suffix) = object_type.split_once(':').ok_or_else(|| { + AppError::bad_request("objectType must be namespaced as object_kind:type") + })?; + if prefix != object_kind.as_str() || suffix.is_empty() { + return Err(AppError::bad_request( + "objectType namespace must match objectKind", + )); + } + } + Ok(()) +} + pub async fn update_capability( pool: &PgPool, id: Uuid, @@ -2713,6 +2926,7 @@ pub async fn create_direct_policy( req: CreateDirectPolicy, ) -> Result { validate_direct_policy(pool, &req).await?; + crate::guardrails::validate_direct_policy(pool, &req).await?; sqlx::query_as::<_, DirectPolicy>( r#"INSERT INTO direct_policies (tenant_id, subject_kind, subject_id, permission_block_id) diff --git a/src/graphql/policies.rs b/src/graphql/policies.rs index efa2a45..e732ab9 100644 --- a/src/graphql/policies.rs +++ b/src/graphql/policies.rs @@ -1,10 +1,13 @@ use async_graphql::{Context, Object, Result, ID}; use crate::{ + audit, auth::{require_capability, Scope}, authz::repo as authz_repo, models::{ + action_assignment_rule::{CreateActionAssignmentRule, ListActionAssignmentRules}, capability::{CreateCapability, ListCapabilities, UpdateCapability}, + enums::AuditOutcome, policy::{ CreateDirectPolicy, CreatePermissionBlock, CreateRoleAssignment, ListDirectPolicies, ListPermissionBlocks, ListRoleAssignments, @@ -17,11 +20,14 @@ use crate::{ use super::{ auth::{gql_error, require_auth, require_policy_read, require_role_read, scope_for_tenant}, types::{ - parse_effect_or_default, parse_id, parse_optional_id, parse_optional_subject_kind, - parse_subject_kind, Action, ActionApplicabilityEntry, ActionApplicabilityList, ActionList, - AddActionApplicabilityInput, CreateActionInput, CreateDirectPolicyInput, - CreatePermissionBlockInput, CreateRoleAssignmentInput, CreateRoleInput, DirectPolicy, - DirectPolicyList, GqlSubjectKind, PermissionBlock, PermissionBlockList, + parse_effect_or_default, parse_id, parse_object_kind, + parse_optional_action_assignment_decision, parse_optional_entity_kind, parse_optional_id, + parse_optional_subject_kind, parse_subject_kind, Action, ActionApplicabilityEntry, + ActionApplicabilityList, ActionAssignmentRule, ActionAssignmentRuleList, ActionList, + AddActionApplicabilityInput, CreateActionAssignmentRuleInput, CreateActionInput, + CreateDirectPolicyInput, CreatePermissionBlockInput, CreateRoleAssignmentInput, + CreateRoleInput, DirectPolicy, DirectPolicyList, GqlActionAssignmentRuleDecision, + GqlEntityKind, GqlSubjectKind, PermissionBlock, PermissionBlockList, RemoveActionApplicabilityInput, Role, RoleAssignment, RoleAssignmentList, RoleList, UpdateActionInput, UpdateRoleInput, }, @@ -141,6 +147,47 @@ impl PolicyQuery { }) } + #[allow(clippy::too_many_arguments)] + async fn action_assignment_rules( + &self, + ctx: &Context<'_>, + tenant_id: Option, + entity_kind: Option, + action_name: Option, + object_kind: Option, + object_type: Option, + decision: Option, + #[graphql(default = 50)] limit: i64, + #[graphql(default = 0)] offset: i64, + ) -> Result { + let auth = require_auth(ctx)?; + let state = ctx.data::()?; + let tenant_id = parse_optional_id(tenant_id, "tenantId")?; + require_policy_read(&state.pool, auth.entity_id, tenant_id).await?; + let object_kind = object_kind + .map(|value| parse_object_kind(value, "objectKind")) + .transpose()?; + let list = authz_repo::list_action_assignment_rules( + &state.pool, + ListActionAssignmentRules { + tenant_id, + entity_kind: parse_optional_entity_kind(entity_kind), + action_name, + object_kind, + object_type, + decision: parse_optional_action_assignment_decision(decision), + limit, + offset, + }, + ) + .await + .map_err(gql_error)?; + Ok(ActionAssignmentRuleList { + items: list.items.into_iter().map(ActionAssignmentRule).collect(), + total: list.total, + }) + } + async fn action(&self, ctx: &Context<'_>, id: ID) -> Result { let auth = require_auth(ctx)?; let state = ctx.data::()?; @@ -456,6 +503,62 @@ impl PolicyMutation { Ok(true) } + async fn create_action_assignment_rule( + &self, + ctx: &Context<'_>, + input: CreateActionAssignmentRuleInput, + ) -> Result { + let auth = require_auth(ctx)?; + let state = ctx.data::()?; + let tenant_id = parse_optional_id(input.tenant_id.clone(), "tenantId")?; + require_capability( + &state.pool, + auth.entity_id, + "policy.manage", + scope_for_tenant(tenant_id), + ) + .await + .map_err(gql_error)?; + let rule = authz_repo::create_action_assignment_rule( + &state.pool, + CreateActionAssignmentRule { + tenant_id, + entity_kind: input.entity_kind.into(), + action_name: input.action_name, + object_kind: parse_object_kind(input.object_kind, "objectKind")?, + object_type: input.object_type, + decision: input.decision.into(), + is_absolute: input.is_absolute.unwrap_or(false), + }, + ) + .await + .map_err(gql_error)?; + audit_action_assignment_rule(&state.pool, auth.entity_id, &rule, "create").await; + Ok(ActionAssignmentRule(rule)) + } + + async fn delete_action_assignment_rule(&self, ctx: &Context<'_>, id: ID) -> Result { + let auth = require_auth(ctx)?; + let state = ctx.data::()?; + let id = parse_id(id, "id")?; + let existing = authz_repo::get_action_assignment_rule(&state.pool, id) + .await + .map_err(gql_error)?; + require_capability( + &state.pool, + auth.entity_id, + "policy.manage", + scope_for_tenant(existing.tenant_id), + ) + .await + .map_err(gql_error)?; + let rule = authz_repo::delete_action_assignment_rule(&state.pool, id) + .await + .map_err(gql_error)?; + audit_action_assignment_rule(&state.pool, auth.entity_id, &rule, "delete").await; + Ok(true) + } + async fn update_action( &self, ctx: &Context<'_>, @@ -675,3 +778,29 @@ impl PolicyMutation { Ok(true) } } + +async fn audit_action_assignment_rule( + pool: &sqlx::PgPool, + actor_id: uuid::Uuid, + rule: &crate::models::action_assignment_rule::ActionAssignmentRule, + action: &str, +) { + audit::write( + pool, + Some(actor_id), + rule.tenant_id, + &format!("action_assignment_rule.{action}"), + AuditOutcome::Allow, + serde_json::json!({ + "rule_id": rule.id, + "entity_kind": &rule.entity_kind, + "action_name": rule.action_name, + "object_kind": rule.object_kind.as_str(), + "object_type": &rule.object_type, + "decision": &rule.decision, + "is_absolute": rule.is_absolute, + "transport": "graphql", + }), + ) + .await; +} diff --git a/src/graphql/schema.rs b/src/graphql/schema.rs index 2864d64..1f0c89e 100644 --- a/src/graphql/schema.rs +++ b/src/graphql/schema.rs @@ -186,6 +186,7 @@ mod tests { "actions", "action", "actionApplicability", + "actionAssignmentRules", "permissionBlocks", "permissionBlock", "roleAssignments", @@ -256,6 +257,8 @@ mod tests { "deleteAction", "addActionApplicability", "removeActionApplicability", + "createActionAssignmentRule", + "deleteActionAssignmentRule", "createPermissionBlock", "deletePermissionBlock", "createRoleAssignment", @@ -421,6 +424,8 @@ mod tests { effect: __type(name: "Effect") { enumValues { name } } credentialKind: __type(name: "CredentialKind") { enumValues { name } } auditOutcome: __type(name: "AuditOutcome") { enumValues { name } } + assignmentRuleDecision: __type(name: "ActionAssignmentRuleDecision") { enumValues { name } } + createAssignmentRuleDecision: __type(name: "CreateActionAssignmentRuleDecision") { enumValues { name } } } "#, )) @@ -451,6 +456,14 @@ mod tests { enum_names(&data, "auditOutcome"), set(&["allow", "deny", "error"]) ); + assert_eq!( + enum_names(&data, "assignmentRuleDecision"), + set(&["allow", "deny", "require_override"]) + ); + assert_eq!( + enum_names(&data, "createAssignmentRuleDecision"), + set(&["allow", "deny"]) + ); } fn test_state() -> AppState { diff --git a/src/graphql/types/mod.rs b/src/graphql/types/mod.rs index f811340..165da0a 100644 --- a/src/graphql/types/mod.rs +++ b/src/graphql/types/mod.rs @@ -7,11 +7,11 @@ use crate::{ authz::repo as authz_repo, identity::{repo as identity_repo, service as identity_service}, models::{ - access as access_model, api_endpoint as api_endpoint_model, capability as capability_model, - entity as entity_model, + access as access_model, action_assignment_rule as action_assignment_rule_model, + api_endpoint as api_endpoint_model, capability as capability_model, entity as entity_model, enums::{ - AuditOutcome, CredentialKind, CredentialStatus, Effect, EntityKind, EntityStatus, - GrantKind, ScopeKind, SubjectKind, TenantStatus, + ActionAssignmentDecision, AuditOutcome, CredentialKind, CredentialStatus, Effect, + EntityKind, EntityStatus, GrantKind, ObjectKind, ScopeKind, SubjectKind, TenantStatus, }, group as group_model, policy as policy_model, profile as profile_model, resource as resource_model, role as role_model, session as session_model, @@ -82,6 +82,24 @@ pub enum GqlEffect { Deny, } +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +#[graphql(name = "ActionAssignmentRuleDecision", rename_items = "snake_case")] +pub enum GqlActionAssignmentRuleDecision { + Allow, + Deny, + RequireOverride, +} + +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +#[graphql( + name = "CreateActionAssignmentRuleDecision", + rename_items = "snake_case" +)] +pub enum GqlCreateActionAssignmentRuleDecision { + Allow, + Deny, +} + #[derive(Enum, Copy, Clone, Eq, PartialEq)] #[graphql(name = "CredentialKind", rename_items = "snake_case")] pub enum GqlCredentialKind { @@ -911,6 +929,47 @@ impl ActionApplicabilityEntry { } } +pub struct ActionAssignmentRule(pub action_assignment_rule_model::ActionAssignmentRule); + +#[Object] +impl ActionAssignmentRule { + async fn id(&self) -> ID { + id(self.0.id) + } + + async fn tenant_id(&self) -> Option { + self.0.tenant_id.map(id) + } + + async fn entity_kind(&self) -> GqlEntityKind { + GqlEntityKind::from(&self.0.entity_kind) + } + + async fn action_name(&self) -> &str { + &self.0.action_name + } + + async fn object_kind(&self) -> &str { + self.0.object_kind.as_str() + } + + async fn object_type(&self) -> Option<&str> { + self.0.object_type.as_deref() + } + + async fn decision(&self) -> GqlActionAssignmentRuleDecision { + GqlActionAssignmentRuleDecision::from(self.0.decision) + } + + async fn is_absolute(&self) -> bool { + self.0.is_absolute + } + + async fn created_at(&self) -> String { + timestamp(self.0.created_at) + } +} + pub struct CapabilityApplicability(pub capability_model::CapabilityApplicability); #[Object] @@ -1594,6 +1653,17 @@ pub struct RemoveActionApplicabilityInput { pub object_type: Option, } +#[derive(InputObject)] +pub struct CreateActionAssignmentRuleInput { + pub tenant_id: Option, + pub entity_kind: GqlEntityKind, + pub action_name: String, + pub object_kind: String, + pub object_type: Option, + pub decision: GqlCreateActionAssignmentRuleDecision, + pub is_absolute: Option, +} + #[derive(InputObject)] pub struct CreatePolicyInput { pub tenant_id: Option, @@ -1856,6 +1926,23 @@ impl ActionApplicabilityList { } } +#[derive(Default)] +pub struct ActionAssignmentRuleList { + pub items: Vec, + pub total: i64, +} + +#[Object] +impl ActionAssignmentRuleList { + async fn items(&self) -> &[ActionAssignmentRule] { + &self.items + } + + async fn total(&self) -> i64 { + self.total + } +} + #[derive(Default)] pub struct PolicyBindingList { pub items: Vec, @@ -2257,6 +2344,12 @@ pub fn parse_effect_or_default(value: Option) -> Effect { value.map(Effect::from).unwrap_or_default() } +pub fn parse_optional_action_assignment_decision( + value: Option, +) -> Option { + value.map(ActionAssignmentDecision::from) +} + pub fn parse_optional_audit_outcome(value: Option) -> Option { value.map(AuditOutcome::from) } @@ -2269,6 +2362,23 @@ pub fn parse_scope_kind(value: GqlScopeKind) -> ScopeKind { ScopeKind::from(value) } +pub fn parse_object_kind(value: String, name: &str) -> async_graphql::Result { + match value.as_str() { + "entity" => Ok(ObjectKind::Entity), + "resource" => Ok(ObjectKind::Resource), + "group" => Ok(ObjectKind::Group), + "tenant" => Ok(ObjectKind::Tenant), + "role" => Ok(ObjectKind::Role), + "policy" => Ok(ObjectKind::Policy), + "credential" => Ok(ObjectKind::Credential), + "audit_log" => Ok(ObjectKind::AuditLog), + "signing_key" => Ok(ObjectKind::SigningKey), + _ => Err(async_graphql::Error::new(format!( + "{name} must be a valid object kind" + ))), + } +} + pub fn parse_optional_tenant_status(value: Option) -> Option { value.map(TenantStatus::from) } @@ -2425,6 +2535,39 @@ impl From<&Effect> for GqlEffect { } } +impl From for ActionAssignmentDecision { + fn from(decision: GqlActionAssignmentRuleDecision) -> Self { + match decision { + GqlActionAssignmentRuleDecision::Allow => ActionAssignmentDecision::Allow, + GqlActionAssignmentRuleDecision::Deny => ActionAssignmentDecision::Deny, + GqlActionAssignmentRuleDecision::RequireOverride => { + ActionAssignmentDecision::RequireOverride + } + } + } +} + +impl From for ActionAssignmentDecision { + fn from(decision: GqlCreateActionAssignmentRuleDecision) -> Self { + match decision { + GqlCreateActionAssignmentRuleDecision::Allow => ActionAssignmentDecision::Allow, + GqlCreateActionAssignmentRuleDecision::Deny => ActionAssignmentDecision::Deny, + } + } +} + +impl From for GqlActionAssignmentRuleDecision { + fn from(decision: ActionAssignmentDecision) -> Self { + match decision { + ActionAssignmentDecision::Allow => GqlActionAssignmentRuleDecision::Allow, + ActionAssignmentDecision::Deny => GqlActionAssignmentRuleDecision::Deny, + ActionAssignmentDecision::RequireOverride => { + GqlActionAssignmentRuleDecision::RequireOverride + } + } + } +} + impl From for AuditOutcome { fn from(outcome: GqlAuditOutcome) -> Self { match outcome { diff --git a/src/guardrails.rs b/src/guardrails.rs index ae9d46b..894b675 100644 --- a/src/guardrails.rs +++ b/src/guardrails.rs @@ -4,18 +4,11 @@ use uuid::Uuid; use crate::{ error::{db_err, AppError}, models::{ - enums::{GrantKind, ScopeKind, SubjectKind}, - policy::CreatePolicyBinding, + enums::{ActionAssignmentDecision as GuardrailDecision, GrantKind, ScopeKind, SubjectKind}, + policy::{CreateDirectPolicy, CreatePolicyBinding}, }, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GuardrailDecision { - Allow, - Deny, - RequireOverride, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct Assignment { pub entity_kind: String, @@ -333,6 +326,72 @@ pub async fn validate_group_member( validate_assignments(pool, &assignments).await } +pub async fn validate_direct_policy( + pool: &PgPool, + req: &CreateDirectPolicy, +) -> Result<(), AppError> { + let entity_kinds = subject_entity_kinds(pool, req.subject_kind.clone(), req.subject_id).await?; + let permission_blocks = permission_block_assignments(pool, &[req.permission_block_id]).await?; + let assignments = entity_kinds + .into_iter() + .flat_map(|entity_kind| { + permission_blocks.iter().map(move |block| Assignment { + entity_kind: entity_kind.clone(), + capability_name: block.capability_name.clone(), + object_kind: block.object_kind.clone(), + object_type: block.object_type.clone(), + tenant_id: req.tenant_id, + }) + }) + .collect::>(); + + validate_assignments(pool, &assignments).await +} + +pub async fn validate_role_permission_block_links( + pool: &PgPool, + role_id: Uuid, + permission_block_ids: &[Uuid], +) -> Result<(), AppError> { + if permission_block_ids.is_empty() { + return Ok(()); + } + + use sqlx::Row; + let rows = sqlx::query( + r#"SELECT ra.tenant_id, e.kind AS entity_kind + FROM role_assignments ra + JOIN entities e ON ra.subject_kind = 'entity' AND e.id = ra.subject_id + WHERE ra.role_id = $1 + UNION ALL + SELECT ra.tenant_id, e.kind AS entity_kind + FROM role_assignments ra + JOIN group_members gm ON ra.subject_kind = 'group' AND gm.group_id = ra.subject_id + JOIN entities e ON e.id = gm.entity_id + WHERE ra.role_id = $1"#, + ) + .bind(role_id) + .fetch_all(pool) + .await + .map_err(db_err)?; + + let permission_blocks = permission_block_assignments(pool, permission_block_ids).await?; + let mut assignments = Vec::new(); + for row in rows { + let tenant_id: Option = row.try_get("tenant_id").map_err(db_err)?; + let entity_kind: String = row.try_get("entity_kind").map_err(db_err)?; + assignments.extend(permission_blocks.iter().map(|block| Assignment { + entity_kind: entity_kind.clone(), + capability_name: block.capability_name.clone(), + object_kind: block.object_kind.clone(), + object_type: block.object_type.clone(), + tenant_id, + })); + } + + validate_assignments(pool, &assignments).await +} + async fn assignments_for_policy( pool: &PgPool, req: &CreatePolicyBinding, @@ -382,19 +441,13 @@ async fn load_rules(pool: &PgPool) -> Result, AppError> { .map_err(db_err)? .into_iter() .map(|row| { - let decision: String = row.try_get("decision").map_err(db_err)?; Ok(Rule { tenant_id: row.try_get("tenant_id").map_err(db_err)?, entity_kind: row.try_get("entity_kind").map_err(db_err)?, capability_name: row.try_get("capability_name").map_err(db_err)?, object_kind: row.try_get("object_kind").map_err(db_err)?, object_type: row.try_get("object_type").map_err(db_err)?, - decision: match decision.as_str() { - "allow" => GuardrailDecision::Allow, - "deny" => GuardrailDecision::Deny, - "require_override" => GuardrailDecision::RequireOverride, - _ => GuardrailDecision::Deny, - }, + decision: row.try_get("decision").map_err(db_err)?, is_absolute: row.try_get("is_absolute").map_err(db_err)?, }) }) @@ -534,6 +587,79 @@ async fn role_permission_assignments( .collect() } +async fn permission_block_assignments( + pool: &PgPool, + permission_block_ids: &[Uuid], +) -> Result, AppError> { + if permission_block_ids.is_empty() { + return Ok(Vec::new()); + } + + use sqlx::Row; + sqlx::query( + r#"SELECT a.name AS capability_name, + CASE + WHEN pb.scope_mode = 'platform' THEN 'platform' + WHEN pb.scope_mode = 'tenant' THEN 'tenant' + WHEN pb.scope_mode IN ('object_kind', 'object_type', 'group_direct_objects', 'group_descendant_objects') THEN pb.object_kind + WHEN pb.scope_mode IN ('group', 'group_child_groups', 'group_descendant_groups') THEN 'group' + WHEN pb.scope_mode = 'object' THEN COALESCE( + target_resource.object_kind, + target_entity.object_kind, + target_group.object_kind, + target_tenant.object_kind, + 'object' + ) + ELSE 'unknown' + END AS object_kind, + CASE + WHEN pb.scope_mode IN ('object_type', 'group_direct_objects', 'group_descendant_objects') THEN pb.object_type + WHEN pb.scope_mode = 'object' THEN COALESCE( + target_resource.object_type, + target_entity.object_type + ) + ELSE NULL + END AS object_type + FROM permission_blocks pb + JOIN permission_block_actions pba ON pba.permission_block_id = pb.id + JOIN actions a ON a.id = pba.action_id + LEFT JOIN LATERAL ( + SELECT 'resource'::text AS object_kind, 'resource:' || kind::text AS object_type + FROM resources + WHERE id = pb.object_id AND pb.scope_mode = 'object' + ) target_resource ON TRUE + LEFT JOIN LATERAL ( + SELECT 'entity'::text AS object_kind, 'entity:' || kind::text AS object_type + FROM entities + WHERE id = pb.object_id AND pb.scope_mode = 'object' + ) target_entity ON TRUE + LEFT JOIN LATERAL ( + SELECT 'group'::text AS object_kind + FROM object_groups + WHERE id = pb.object_id AND pb.scope_mode = 'object' + ) target_group ON TRUE + LEFT JOIN LATERAL ( + SELECT 'tenant'::text AS object_kind + FROM tenants + WHERE id = pb.object_id AND pb.scope_mode = 'object' + ) target_tenant ON TRUE + WHERE pb.id = ANY($1::uuid[])"#, + ) + .bind(permission_block_ids) + .fetch_all(pool) + .await + .map_err(db_err)? + .into_iter() + .map(|row| { + Ok(RoleCapabilityAssignment { + capability_name: row.try_get("capability_name").map_err(db_err)?, + object_kind: row.try_get("object_kind").map_err(db_err)?, + object_type: row.try_get("object_type").map_err(db_err)?, + }) + }) + .collect() +} + fn scope_to_object(scope_kind: ScopeKind, scope_ref: Option<&str>) -> (String, Option) { match scope_kind { ScopeKind::Platform => ("platform".to_string(), None), diff --git a/src/models/action_assignment_rule.rs b/src/models/action_assignment_rule.rs new file mode 100644 index 0000000..e8a194a --- /dev/null +++ b/src/models/action_assignment_rule.rs @@ -0,0 +1,47 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::enums::{ActionAssignmentDecision, EntityKind, ObjectKind}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ActionAssignmentRule { + pub id: Uuid, + pub tenant_id: Option, + pub entity_kind: EntityKind, + pub action_name: String, + pub object_kind: ObjectKind, + pub object_type: Option, + pub decision: ActionAssignmentDecision, + pub is_absolute: bool, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CreateActionAssignmentRule { + pub tenant_id: Option, + pub entity_kind: EntityKind, + pub action_name: String, + pub object_kind: ObjectKind, + pub object_type: Option, + pub decision: ActionAssignmentDecision, + pub is_absolute: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ListActionAssignmentRules { + pub tenant_id: Option, + pub entity_kind: Option, + pub action_name: Option, + pub object_kind: Option, + pub object_type: Option, + pub decision: Option, + pub limit: i64, + pub offset: i64, +} + +#[derive(Debug, Serialize)] +pub struct ActionAssignmentRuleList { + pub items: Vec, + pub total: i64, +} diff --git a/src/models/enums.rs b/src/models/enums.rs index 1e1d119..6ac180c 100644 --- a/src/models/enums.rs +++ b/src/models/enums.rs @@ -118,6 +118,15 @@ impl ObjectKind { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum ActionAssignmentDecision { + Allow, + Deny, + RequireOverride, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "text", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] diff --git a/src/models/mod.rs b/src/models/mod.rs index 7652176..a051584 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod access; +pub mod action_assignment_rule; pub mod api_endpoint; pub mod capability; pub mod entity; diff --git a/tests/m16_action_assignment_rules.rs b/tests/m16_action_assignment_rules.rs new file mode 100644 index 0000000..7ddbb18 --- /dev/null +++ b/tests/m16_action_assignment_rules.rs @@ -0,0 +1,233 @@ +//! DB-gated tests for action assignment guardrail management. + +mod common; + +use atom::{ + authz::repo, + error::AppError, + models::{ + action_assignment_rule::{CreateActionAssignmentRule, ListActionAssignmentRules}, + enums::{ActionAssignmentDecision, Effect, EntityKind, ObjectKind, SubjectKind}, + policy::{CreateDirectPolicy, CreatePermissionBlock, CreateRoleAssignment}, + role::CreateRole, + }, +}; + +#[tokio::test] +#[ignore] +async fn repo_validates_action_assignment_rule_creation() { + let p = common::pool().await; + let action_name = format!("m16-action-{}", uuid::Uuid::new_v4()); + sqlx::query("INSERT INTO actions (name, description) VALUES ($1, 'm16 test action')") + .bind(&action_name) + .execute(&p) + .await + .expect("insert action"); + + let created = repo::create_action_assignment_rule( + &p, + CreateActionAssignmentRule { + tenant_id: None, + entity_kind: EntityKind::Device, + action_name: action_name.clone(), + object_kind: ObjectKind::Resource, + object_type: Some("resource:channel".into()), + decision: ActionAssignmentDecision::Allow, + is_absolute: false, + }, + ) + .await + .expect("create rule"); + assert_eq!(created.action_name, action_name); + + let listed = repo::list_action_assignment_rules( + &p, + ListActionAssignmentRules { + tenant_id: None, + entity_kind: Some(EntityKind::Device), + action_name: Some(created.action_name.clone()), + object_kind: Some(ObjectKind::Resource), + object_type: Some("resource:channel".into()), + decision: Some(ActionAssignmentDecision::Allow), + limit: 10, + offset: 0, + }, + ) + .await + .expect("list rules"); + assert_eq!(listed.total, 1); + + let duplicate = repo::create_action_assignment_rule( + &p, + CreateActionAssignmentRule { + tenant_id: None, + entity_kind: EntityKind::Device, + action_name: created.action_name.clone(), + object_kind: ObjectKind::Resource, + object_type: Some("resource:channel".into()), + decision: ActionAssignmentDecision::Deny, + is_absolute: false, + }, + ) + .await + .expect_err("duplicate rule rejected"); + assert!(matches!(duplicate, AppError::Conflict(_))); + + let unknown = repo::create_action_assignment_rule( + &p, + CreateActionAssignmentRule { + tenant_id: None, + entity_kind: EntityKind::Device, + action_name: "missing-action".into(), + object_kind: ObjectKind::Resource, + object_type: None, + decision: ActionAssignmentDecision::Allow, + is_absolute: false, + }, + ) + .await + .expect_err("unknown action rejected"); + assert!(matches!(unknown, AppError::BadRequest(_))); + + let invalid_object_type = repo::create_action_assignment_rule( + &p, + CreateActionAssignmentRule { + tenant_id: None, + entity_kind: EntityKind::Device, + action_name: created.action_name.clone(), + object_kind: ObjectKind::Resource, + object_type: Some("entity:device".into()), + decision: ActionAssignmentDecision::Allow, + is_absolute: false, + }, + ) + .await + .expect_err("invalid object type rejected"); + assert!(matches!(invalid_object_type, AppError::BadRequest(_))); + + let tenant_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO tenants (id, name, status) VALUES ($1, $2, 'active')") + .bind(tenant_id) + .bind(format!("m16-tenant-{tenant_id}")) + .execute(&p) + .await + .expect("insert tenant"); + + for req in [ + CreateActionAssignmentRule { + tenant_id: Some(tenant_id), + entity_kind: EntityKind::Device, + action_name: created.action_name.clone(), + object_kind: ObjectKind::Resource, + object_type: None, + decision: ActionAssignmentDecision::Allow, + is_absolute: false, + }, + CreateActionAssignmentRule { + tenant_id: Some(tenant_id), + entity_kind: EntityKind::Device, + action_name: created.action_name.clone(), + object_kind: ObjectKind::Resource, + object_type: None, + decision: ActionAssignmentDecision::Deny, + is_absolute: true, + }, + CreateActionAssignmentRule { + tenant_id: None, + entity_kind: EntityKind::Device, + action_name: created.action_name.clone(), + object_kind: ObjectKind::Resource, + object_type: None, + decision: ActionAssignmentDecision::RequireOverride, + is_absolute: false, + }, + ] { + let err = repo::create_action_assignment_rule(&p, req) + .await + .expect_err("invalid v1 rule rejected"); + assert!(matches!(err, AppError::BadRequest(_))); + } + + let deleted = repo::delete_action_assignment_rule(&p, created.id) + .await + .expect("delete rule"); + assert_eq!(deleted.id, created.id); +} + +#[tokio::test] +#[ignore] +async fn guardrails_apply_to_direct_policy_and_role_permission_block_links() { + let p = common::pool().await; + let device_id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO entities (id, kind, name, status) VALUES ($1, 'device', $2, 'active')", + ) + .bind(device_id) + .bind(format!("m16-device-{device_id}")) + .execute(&p) + .await + .expect("insert device"); + + let manage_action_id: uuid::Uuid = + sqlx::query_scalar("SELECT id FROM actions WHERE name = 'manage'") + .fetch_one(&p) + .await + .expect("manage action"); + + let block = repo::create_permission_block( + &p, + CreatePermissionBlock { + tenant_id: None, + scope_mode: "object_kind".into(), + object_kind: Some("resource".into()), + object_type: None, + object_id: None, + group_id: None, + effect: Effect::Allow, + conditions: serde_json::json!({}), + action_ids: vec![manage_action_id], + }, + ) + .await + .expect("create permission block"); + + let direct_policy = repo::create_direct_policy( + &p, + CreateDirectPolicy { + tenant_id: None, + subject_kind: SubjectKind::Entity, + subject_id: device_id, + permission_block_id: block.id, + }, + ) + .await + .expect_err("direct policy guardrail rejected"); + assert!(matches!(direct_policy, AppError::BadRequest(_))); + + let role = repo::create_role( + &p, + CreateRole { + name: format!("m16-role-{}", uuid::Uuid::new_v4()), + tenant_id: None, + description: None, + }, + ) + .await + .expect("create role"); + repo::create_role_assignment( + &p, + CreateRoleAssignment { + tenant_id: None, + subject_kind: SubjectKind::Entity, + subject_id: device_id, + role_id: role.id, + }, + ) + .await + .expect("create empty role assignment"); + + let role_link = repo::replace_role_permission_block_links(&p, role.id, &[block.id]) + .await + .expect_err("role link guardrail rejected"); + assert!(matches!(role_link, AppError::BadRequest(_))); +} diff --git a/tests/m1_schema.rs b/tests/m1_schema.rs index 5d27087..ccc4231 100644 --- a/tests/m1_schema.rs +++ b/tests/m1_schema.rs @@ -172,6 +172,21 @@ async fn action_assignment_rules_table_exists_with_object_type() { .expect("read decision"); assert_eq!(decision, "allow"); + let dup = sqlx::query( + "INSERT INTO action_assignment_rules + (id, entity_kind, action_name, object_kind, object_type, decision, is_absolute) + VALUES ($1, 'device', 'publish', 'resource', 'resource:channel', 'allow', true)", + ) + .bind(uuid::Uuid::new_v4()) + .execute(&p) + .await + .expect_err("duplicate rule must be rejected"); + let duplicate_code = dup + .as_database_error() + .and_then(|err| err.code()) + .map(|code| code.into_owned()); + assert_eq!(duplicate_code.as_deref(), Some("23505")); + let _ = sqlx::query("DELETE FROM action_assignment_rules WHERE id = $1") .bind(id) .execute(&p)