Skip to content

Commit 854e095

Browse files
authored
feat(access-control): govern access requests per permission item (#9483)
* feat(access-control): govern access requests per permission item * fix(access-control): clarify request availability states * chore(access-control): document and type permission controls * improvement(access-control): align permission rows and contextual help * fix(access-control): dismiss floating permission help with Escape
1 parent 08bdbea commit 854e095

35 files changed

Lines changed: 1443 additions & 1078 deletions

File tree

‎apps/docs/content/docs/platform/enterprise/access-control.mdx‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,23 @@ Organization owners and admins manage groups under **Settings → Organization
2020
Changes to workspace scope, the default-group switch, and membership take effect immediately. **Discard** only discards the buffered name, description, and permission changes.
2121
</Callout>
2222

23-
In the provider, block, and platform permission lists, a checked box allows the item. Clear it to restrict the item. The **Project Issues** project checklist is an exception: checked projects are restricted.
23+
Choose an access state in the provider, block, and platform permission lists:
24+
25+
| State | What members can do |
26+
| --- | --- |
27+
| **Allowed** | Use the item, subject to their workspace role and deployment settings. |
28+
| **Can request** | Discover the item and ask an administrator for access when their role and deployment settings permit it. Use stays blocked until approved. |
29+
| **Unavailable** | Cannot use or request the item. It is omitted from normal discovery; direct links remain restricted. |
30+
31+
Use **Unavailable** for software your organization has not procured. Existing groups retain their permissions and request behavior until you change them. **Project Issues** keeps its separate checklist: checked projects are restricted.
32+
33+
Search and the state filter narrow each list. **Set shown to…** changes only the visible, editable rows and offers choices supported by all of them. Changes remain a draft until **Save**; **Discard** restores the saved configuration.
34+
35+
A restricted parent also restricts its children. Expand a provider, integration, or platform feature to inspect its models, tools, connectors, or authentication modes. Allow the parent before editing its children. Child choices are preserved when a parent changes. An unavailable child stays unavailable even if its parent allows requests. Requests cannot bypass an unavailable parent.
36+
37+
The info icon beside an item explains its restriction and, for inherited choices, names the parent to change. Hover or focus the icon to read the help, or click or tap to keep it open. Press Escape or click outside to dismiss it.
38+
39+
These choices configure the group's policy, independently of the deployment's current availability. **Allowed** and **Can request** cannot enable a deployment-disabled feature or bypass a workspace role. Members only discover and request items available to their deployment and role. Custom blocks, private dynamically discovered models, and other items outside the public request catalog do not offer **Can request**.
2440

2541
### General
2642

@@ -47,7 +63,7 @@ The **Agent default** chooses the initial model for new Agent blocks. Choose an
4763

4864
### Blocks
4965

50-
**Core Blocks** contains blocks such as Agent, API, and Function; **Tools** contains integration blocks. All are allowed by default. Select a subset to restrict block types, then expand an integration to deny individual operations—for example, allow reads while refusing deletes.
66+
**Core Blocks** contains blocks such as Agent, API, and Function; **Integrations and Triggers** contains integration blocks. All are allowed by default. Select a subset to restrict block types, then expand an integration to deny individual operations—for example, allow reads while refusing deletes.
5167

5268
The `start_trigger` entry block is always allowed. Existing workflows are not rewritten when a restriction changes. A run fails if it reaches a blocked type, provider, model, or tool operation.
5369

@@ -104,7 +120,7 @@ Restricting **Tool Auto-Approval** prevents remembering approval for future call
104120

105121
#### Organization-level permissions
106122

107-
**Workspace Creation** and **Member Directory** read only the organization default, because those actions have no workspace scope. Other groups show them as inactive with an **Organization** tag, and **Select All** skips them. A newly created workspace uses the organization default until you add it to a workspace group.
123+
**Workspace Creation** and **Member Directory** read only the organization default, because those actions have no workspace scope. Other groups show them as inactive with an **Organization** tag, and **Set shown to…** skips them. A newly created workspace uses the organization default until you add it to a workspace group.
108124

109125
**Integrations**, **API Keys**, **Invitations**, **Personal API Keys**, **CLI Access**, and **OAuth App Access** use the selected workspace group for workspace operations. Account-level operations use the organization default: minting a personal key, sending organization invitations, approving CLI login, authorizing or refreshing OAuth, and account-level billing or audit reads.
110126

@@ -116,6 +132,14 @@ A **workspace API key** acts as the workspace. It has workspace-role and operati
116132

117133
Chat filters restricted blocks from its available block catalog. Generated steps can still reference a restricted MCP tool, custom tool, or skill; execution rejects the restricted operation when reached. After changing a group, verify both the person's available UI and a representative authorized run. For an existing workflow that fails, remove the restricted item or change the governing policy.
118134

135+
## Access requests
136+
137+
Members can request items marked **Can request** from a locked feature or **Settings → Requests → Browse access**. Administrators review the request and its impact before approving. Approval updates the governing permission group, so it affects everyone governed by that group; it is not a personal exception.
138+
139+
An organization administrator can pause requests in **Settings → Requests → Review requests**. Requestable features remain discoverable, but new requests and approvals are disabled. The group's saved choices, request history, and cancellation remain available. Changing an item to **Unavailable** also prevents approval of an older pending request for that item or a dependent child. Administrators can still decline those requests.
140+
141+
The API keeps the existing access fields. The optional `deniedAccessRequests` list stores canonical target keys such as `integration:salesforce` and `feature:hideTablesTab`. An absent or empty list preserves the previous request behavior. Partial configuration updates retain unspecified choices. Request approval never changes this list. Member credit-limit requests remain separate from permission-group access settings.
142+
119143
## Self-hosted setup
120144

121145
On billing-disabled deployments, enable permission groups through the enterprise feature configuration or explicitly set:

‎apps/docs/openapi-v2-resources.json‎

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6953,7 +6953,8 @@
69536953
"disableOAuthAppAccess": false,
69546954
"disableKnowledgeBaseExport": false,
69556955
"deniedPartialAccessProjectIssues": [],
6956-
"defaultAgentModel": null
6956+
"defaultAgentModel": null,
6957+
"deniedAccessRequests": []
69576958
},
69586959
"isDefault": false,
69596960
"membershipMode": "inherit",
@@ -7113,7 +7114,8 @@
71137114
"disableOAuthAppAccess": false,
71147115
"disableKnowledgeBaseExport": false,
71157116
"deniedPartialAccessProjectIssues": [],
7116-
"defaultAgentModel": null
7117+
"defaultAgentModel": null,
7118+
"deniedAccessRequests": []
71177119
},
71187120
"isDefault": false,
71197121
"membershipMode": "inherit",
@@ -7273,7 +7275,8 @@
72737275
"disableOAuthAppAccess": false,
72747276
"disableKnowledgeBaseExport": false,
72757277
"deniedPartialAccessProjectIssues": [],
7276-
"defaultAgentModel": null
7278+
"defaultAgentModel": null,
7279+
"deniedAccessRequests": []
72777280
},
72787281
"isDefault": false,
72797282
"membershipMode": "inherit",
@@ -7441,7 +7444,8 @@
74417444
"disableOAuthAppAccess": false,
74427445
"disableKnowledgeBaseExport": false,
74437446
"deniedPartialAccessProjectIssues": [],
7444-
"defaultAgentModel": null
7447+
"defaultAgentModel": null,
7448+
"deniedAccessRequests": []
74457449
},
74467450
"isDefault": false,
74477451
"membershipMode": "inherit",
@@ -21256,6 +21260,16 @@
2125621260
}
2125721261
],
2125821262
"description": "Default model for new Agent blocks. Null uses the platform default."
21263+
},
21264+
"deniedAccessRequests": {
21265+
"default": [],
21266+
"type": "array",
21267+
"items": {
21268+
"type": "string",
21269+
"minLength": 1,
21270+
"maxLength": 1024
21271+
},
21272+
"description": "Access requests are unavailable for the listed canonical target keys when access is restricted."
2125921273
}
2126021274
},
2126121275
"required": [
@@ -21302,7 +21316,8 @@
2130221316
"disableOAuthAppAccess",
2130321317
"disableKnowledgeBaseExport",
2130421318
"deniedPartialAccessProjectIssues",
21305-
"defaultAgentModel"
21319+
"defaultAgentModel",
21320+
"deniedAccessRequests"
2130621321
],
2130721322
"additionalProperties": false,
2130821323
"description": "Resolved group settings. True disables a boolean capability; null allowlists permit every value and empty allowlists permit none. defaultAgentModel selects the default for new Agent blocks without restricting model selection."
@@ -21434,7 +21449,8 @@
2143421449
"disableOAuthAppAccess": false,
2143521450
"disableKnowledgeBaseExport": false,
2143621451
"deniedPartialAccessProjectIssues": [],
21437-
"defaultAgentModel": null
21452+
"defaultAgentModel": null,
21453+
"deniedAccessRequests": []
2143821454
},
2143921455
"isDefault": false,
2144021456
"membershipMode": "inherit",
@@ -21511,7 +21527,8 @@
2151121527
"disableOAuthAppAccess": false,
2151221528
"disableKnowledgeBaseExport": false,
2151321529
"deniedPartialAccessProjectIssues": [],
21514-
"defaultAgentModel": null
21530+
"defaultAgentModel": null,
21531+
"deniedAccessRequests": []
2151521532
},
2151621533
"isDefault": false,
2151721534
"membershipMode": "inherit",
@@ -21786,6 +21803,15 @@
2178621803
}
2178721804
],
2178821805
"description": "Default model for new Agent blocks. Null uses the platform default."
21806+
},
21807+
"deniedAccessRequests": {
21808+
"type": "array",
21809+
"items": {
21810+
"type": "string",
21811+
"minLength": 1,
21812+
"maxLength": 1024
21813+
},
21814+
"description": "Access requests are unavailable for the listed canonical target keys when access is restricted."
2178921815
}
2179021816
},
2179121817
"additionalProperties": false,
@@ -21879,7 +21905,8 @@
2187921905
"disableOAuthAppAccess": false,
2188021906
"disableKnowledgeBaseExport": false,
2188121907
"deniedPartialAccessProjectIssues": [],
21882-
"defaultAgentModel": null
21908+
"defaultAgentModel": null,
21909+
"deniedAccessRequests": []
2188321910
},
2188421911
"isDefault": false,
2188521912
"membershipMode": "inherit",
@@ -21954,7 +21981,8 @@
2195421981
"disableOAuthAppAccess": false,
2195521982
"disableKnowledgeBaseExport": false,
2195621983
"deniedPartialAccessProjectIssues": [],
21957-
"defaultAgentModel": null
21984+
"defaultAgentModel": null,
21985+
"deniedAccessRequests": []
2195821986
},
2195921987
"isDefault": false,
2196021988
"membershipMode": "inherit",
@@ -22236,6 +22264,15 @@
2223622264
}
2223722265
],
2223822266
"description": "Default model for new Agent blocks. Null uses the platform default."
22267+
},
22268+
"deniedAccessRequests": {
22269+
"type": "array",
22270+
"items": {
22271+
"type": "string",
22272+
"minLength": 1,
22273+
"maxLength": 1024
22274+
},
22275+
"description": "Access requests are unavailable for the listed canonical target keys when access is restricted."
2223922276
}
2224022277
},
2224122278
"additionalProperties": false,
@@ -24600,6 +24637,16 @@
2460024637
}
2460124638
],
2460224639
"description": "Default model for new Agent blocks. Null uses the platform default."
24640+
},
24641+
"deniedAccessRequests": {
24642+
"default": [],
24643+
"type": "array",
24644+
"items": {
24645+
"type": "string",
24646+
"minLength": 1,
24647+
"maxLength": 1024
24648+
},
24649+
"description": "Access requests are unavailable for the listed canonical target keys when access is restricted."
2460324650
}
2460424651
},
2460524652
"required": [
@@ -24646,7 +24693,8 @@
2464624693
"disableOAuthAppAccess",
2464724694
"disableKnowledgeBaseExport",
2464824695
"deniedPartialAccessProjectIssues",
24649-
"defaultAgentModel"
24696+
"defaultAgentModel",
24697+
"deniedAccessRequests"
2465024698
],
2465124699
"additionalProperties": false
2465224700
},

‎apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,9 @@ interface IntegrationBlockDetailProps {
6767
}
6868

6969
/**
70-
* Integration detail page, shown only when the viewer's access policy permits
71-
* the integration (an unconfigured one included, which the page presents as
72-
* unavailable). A direct link to a restricted one renders an access-required
73-
* state instead of the connect actions, and nothing renders until the policy
74-
* has loaded.
70+
* Shows integration content only when the viewer's resolved policy permits it.
71+
* Restricted integrations show access and request status, with loading and retry
72+
* states while that status is being checked.
7573
*/
7674
export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) {
7775
const { getAccess, availabilityError, refetchAvailability } = useIntegrationAccess(workspaceId)
@@ -93,6 +91,22 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
9391
return <IntegrationBlockDetailContent integration={integration} workspaceId={workspaceId} />
9492
}
9593

94+
if (access === 'requestable') {
95+
if (accessRequests.isError) {
96+
return (
97+
<IntegrationAccessNotice
98+
workspaceId={workspaceId}
99+
title='Unable to check access'
100+
description={accessRequests.error.message}
101+
action={<Chip onClick={() => void accessRequests.refetch()}>Try again</Chip>}
102+
/>
103+
)
104+
}
105+
if (!accessRequests.data) {
106+
return <IntegrationBlockDetailFallback workspaceId={workspaceId} />
107+
}
108+
}
109+
96110
const requestable = access === 'requestable' && accessRequests.data?.enabled === true
97111

98112
return (
@@ -102,7 +116,9 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
102116
description={
103117
requestable
104118
? 'Ask an administrator for access.'
105-
: 'Your organization restricts this integration.'
119+
: access === 'requestable' && accessRequests.data?.enabled === false
120+
? 'Access requests are paused for your organization.'
121+
: 'Your organization restricts this integration.'
106122
}
107123
action={
108124
requestable ? (

‎apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ interface IntegrationItemProps {
8080
description?: string | null
8181
icon: ComponentType<{ className?: string }>
8282
unavailable?: boolean
83+
restricted?: boolean
8384
/** Set for an integration the viewer's policy restricts but lets them request. */
8485
onRequestAccess?: () => void
8586
}
@@ -92,9 +93,9 @@ function IntegrationItem({
9293
description,
9394
icon: Icon,
9495
unavailable = false,
96+
restricted = false,
9597
onRequestAccess,
9698
}: IntegrationItemProps) {
97-
const restricted = onRequestAccess !== undefined
9899
return (
99100
<SettingsResourceRow
100101
iconVariant='custom'
@@ -110,9 +111,9 @@ function IntegrationItem({
110111
<Lock className='size-[14px] text-[var(--text-icon)]' aria-hidden />
111112
) : undefined
112113
}
113-
href={restricted ? undefined : `/workspace/${workspaceId}/integrations/${slug}`}
114+
href={onRequestAccess ? undefined : `/workspace/${workspaceId}/integrations/${slug}`}
114115
onClick={onRequestAccess}
115-
clickLabel={restricted ? `Request access to ${name}` : `Open ${name}`}
116+
clickLabel={onRequestAccess ? `Request access to ${name}` : `Open ${name}`}
116117
navigable={!unavailable && !restricted}
117118
disabled={unavailable}
118119
/>
@@ -248,15 +249,15 @@ function IntegrationsContent() {
248249

249250
/**
250251
* Usable and unconfigured integrations are listed, the latter as unavailable;
251-
* a requestable one is listed as a locked row while access requests are on.
252+
* a requestable one stays listed as a locked row while requests are paused.
252253
* Nothing is listed until the access policy has loaded.
253254
*/
254255
const isListed = useCallback(
255256
(blockType: string) => {
256257
const access = getAccess(blockType)
257-
return isIntegrationPermitted(access) || (access === 'requestable' && accessRequestsEnabled)
258+
return isIntegrationPermitted(access) || access === 'requestable'
258259
},
259-
[getAccess, accessRequestsEnabled]
260+
[getAccess]
260261
)
261262

262263
const listedFeatured = useMemo(
@@ -469,8 +470,9 @@ function IntegrationsContent() {
469470
description={integration.description}
470471
icon={Icon}
471472
unavailable={access === 'unconfigured'}
473+
restricted={access === 'requestable'}
472474
onRequestAccess={
473-
access === 'requestable'
475+
access === 'requestable' && accessRequestsEnabled
474476
? () => setRequestedIntegration(integration)
475477
: undefined
476478
}

‎apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,13 @@ export const groupSearchUrlKeys = {
178178
*/
179179
export const groupStatusParam = {
180180
key: 'group-status',
181-
parser: parseAsStringLiteral(['all', 'enabled', 'disabled'] as const).withDefault('all'),
181+
parser: parseAsStringLiteral([
182+
'all',
183+
'enabled',
184+
'disabled',
185+
'requestable',
186+
'unavailable',
187+
] as const).withDefault('all'),
182188
} as const
183189

184190
/** Filter view-state: clean URLs, no back-stack churn. */

‎apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ interface SettingsResourceRowProps {
9090
* the bleed would force a horizontal scrollbar.
9191
*/
9292
flush?: boolean
93+
/** Compact single-line controls retain a 40px row without changing the default resource layout. */
94+
density?: 'default' | 'compact'
9395
/** Renders the row as unavailable without an activation target. */
9496
disabled?: boolean
9597
}
@@ -133,6 +135,7 @@ export function SettingsResourceRow({
133135
clickLabel,
134136
navigable = false,
135137
flush = false,
138+
density = 'default',
136139
disabled = false,
137140
}: SettingsResourceRowProps) {
138141
const describedById = useId()
@@ -202,7 +205,9 @@ export function SettingsResourceRow({
202205
// mixing clickable and static rows keeps one height and one inset.
203206
const rowClass = cn(
204207
'flex items-center justify-between gap-2.5',
205-
!flush && '-mx-2 rounded-lg p-2',
208+
!flush && '-mx-2 rounded-lg',
209+
!flush && (density === 'compact' ? 'px-2' : 'p-2'),
210+
density === 'compact' && 'min-h-10 max-md:pointer-coarse:min-h-11',
206211
disabled && 'opacity-50'
207212
)
208213

0 commit comments

Comments
 (0)