Skip to content

Commit f63e5a9

Browse files
committed
fix(api): enforce current invitation and organization scope
1 parent 79edeee commit f63e5a9

25 files changed

Lines changed: 796 additions & 63 deletions

File tree

‎.github/workflows/test-build.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ jobs:
128128
lib/billing/core/organization-activity.postgres.test.ts
129129
lib/billing/core/usage-analytics-queries.postgres.test.ts
130130
lib/billing/core/organization-usage-pagination.postgres.test.ts
131+
lib/billing/organizations/member-limits.postgres.test.ts
131132
lib/billing/calculations/usage-reservation.test.ts
132133
133134
- name: Verify access request pagination and impact in PostgreSQL

‎apps/docs/content/docs/cli/organizations.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ Get Organization Member Credit Limit (OAuth login or personal API key required)
418418

419419
| Argument | Required | Description |
420420
| --- | --- | --- |
421-
| `userId` | Yes | User ID from List Organization Members or List Workspace Members, including external workspace collaborators. |
421+
| `userId` | Yes | User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it. |
422422

423423
</CommandTable>
424424

@@ -446,7 +446,7 @@ Update Organization Member Credit Limit (OAuth login or personal API key require
446446

447447
| Argument | Required | Description |
448448
| --- | --- | --- |
449-
| `userId` | Yes | User ID from List Organization Members or List Workspace Members, including external workspace collaborators. |
449+
| `userId` | Yes | User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it. |
450450

451451
</CommandTable>
452452

‎apps/docs/content/docs/cli/reference.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3409,7 +3409,7 @@ sim organizations members usage-limit get <userId> [options]
34093409

34103410
| Argument | Required | Description |
34113411
| --- | --- | --- |
3412-
| `userId` | Yes | User ID from List Organization Members or List Workspace Members, including external workspace collaborators. |
3412+
| `userId` | Yes | User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it. |
34133413

34143414
</CommandTable>
34153415

@@ -3437,7 +3437,7 @@ sim organizations members usage-limit update <userId> [options]
34373437

34383438
| Argument | Required | Description |
34393439
| --- | --- | --- |
3440-
| `userId` | Yes | User ID from List Organization Members or List Workspace Members, including external workspace collaborators. |
3440+
| `userId` | Yes | User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it. |
34413441

34423442
</CommandTable>
34433443

‎apps/docs/lib/openapi-download.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe('OpenAPI download', () => {
3333
const tags = document.tags as Array<{ name: string }>
3434

3535
expect(document.openapi).toBe('3.1.0')
36-
expect(Object.keys(paths)).toHaveLength(170)
36+
expect(Object.keys(paths)).toHaveLength(187)
3737
expect(tags.map((tag) => tag.name)).toEqual([
3838
'Workspace Sync',
3939
'Workflows',
@@ -44,6 +44,7 @@ describe('OpenAPI download', () => {
4444
'Tables',
4545
'Knowledge Bases',
4646
'Billing',
47+
'Access Requests',
4748
'Organizations',
4849
'Permission Groups',
4950
'Meta',

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7016,7 +7016,7 @@
70167016
"get": {
70177017
"operationId": "getOrganizationMemberUsageLimit",
70187018
"summary": "Get Organization Member Credit Limit",
7019-
"description": "Read a person’s credit cap and credits consumed in the organization billing period. Hosted only. The userId identifies a user, including an external workspace collaborator; it is not a membership record ID. Null means no per-person cap, while organization limits still apply. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.",
7019+
"description": "Read a person’s credit cap and credits consumed in the organization billing period. Hosted only. The userId identifies an organization member or external collaborator with workspace access in this organization; it is not a membership record ID. Null means no per-person cap, while organization limits still apply. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.",
70207020
"x-sim-operation": "organization_member_usage_limits.read",
70217021
"x-oauth-scope": "api:read",
70227022
"tags": ["Organizations"],
@@ -7036,11 +7036,11 @@
70367036
"name": "userId",
70377037
"in": "path",
70387038
"required": true,
7039-
"description": "User ID from List Organization Members or List Workspace Members, including external workspace collaborators.",
7039+
"description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it.",
70407040
"schema": {
70417041
"type": "string",
70427042
"minLength": 1,
7043-
"description": "User ID from List Organization Members or List Workspace Members, including external workspace collaborators."
7043+
"description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it."
70447044
}
70457045
}
70467046
],
@@ -7092,7 +7092,7 @@
70927092
"patch": {
70937093
"operationId": "updateOrganizationMemberUsageLimit",
70947094
"summary": "Update Organization Member Credit Limit",
7095-
"description": "Set or clear a person’s credit cap. Hosted only. The userId can identify an external workspace collaborator. The cap is a nonnegative whole number of credits, not dollars: 0 prevents further credit-consuming usage; null removes the per-person cap. Organization limits continue to apply. Retrying the same value is safe. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.",
7095+
"description": "Set or clear a person’s credit cap. Hosted only. The userId must identify an organization member or external collaborator with workspace access in this organization. The cap is a nonnegative whole number of credits, not dollars: 0 prevents further credit-consuming usage; null removes the per-person cap. Organization limits continue to apply. Retrying the same value is safe. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.",
70967096
"x-sim-operation": "organization_member_usage_limits.update",
70977097
"x-oauth-scope": "api:write",
70987098
"tags": ["Organizations"],
@@ -7112,11 +7112,11 @@
71127112
"name": "userId",
71137113
"in": "path",
71147114
"required": true,
7115-
"description": "User ID from List Organization Members or List Workspace Members, including external workspace collaborators.",
7115+
"description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it.",
71167116
"schema": {
71177117
"type": "string",
71187118
"minLength": 1,
7119-
"description": "User ID from List Organization Members or List Workspace Members, including external workspace collaborators."
7119+
"description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it."
71207120
}
71217121
}
71227122
],
@@ -18624,7 +18624,7 @@
1862418624
"properties": {
1862518625
"id": {
1862618626
"type": "string",
18627-
"description": "Group or event identifier; an empty group ID represents unattributed usage."
18627+
"description": "Group identifier; an empty ID represents unattributed usage."
1862818628
},
1862918629
"label": {
1863018630
"type": "string",

‎apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,25 @@ const {
2020
mockGetOrgMemberUsageForCurrentPeriod,
2121
mockSetOrgMemberUsageLimit,
2222
mockGetOrganizationSubscription,
23+
mockIsOrgMemberUsageLimitTarget,
2324
} = vi.hoisted(() => ({
2425
mockGetOrgMemberUsageLimit: vi.fn(),
2526
mockGetOrgMemberUsageForCurrentPeriod: vi.fn(),
2627
mockSetOrgMemberUsageLimit: vi.fn(),
2728
mockGetOrganizationSubscription: vi.fn(),
29+
mockIsOrgMemberUsageLimitTarget: vi.fn(),
2830
}))
2931

3032
vi.mock('@sim/audit', () => auditMock)
3133

3234
vi.mock('@/lib/permission-groups/resolve.server', () => ({
3335
getUserPermissionConfigForOrganization: vi.fn().mockResolvedValue(null),
3436
}))
35-
vi.mock('@/lib/users/queries', () => ({
36-
getUserProfile: vi.fn().mockResolvedValue({ id: 'user-2' }),
37-
}))
38-
3937
vi.mock('@/lib/billing/organizations/member-limits', () => ({
4038
getOrgMemberUsageForCurrentPeriod: mockGetOrgMemberUsageForCurrentPeriod,
4139
getOrgMemberUsageLimit: mockGetOrgMemberUsageLimit,
4240
setOrgMemberUsageLimit: mockSetOrgMemberUsageLimit,
41+
isOrgMemberUsageLimitTarget: mockIsOrgMemberUsageLimitTarget,
4342
}))
4443

4544
vi.mock('@/lib/billing/core/billing', () => ({
@@ -74,6 +73,7 @@ describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => {
7473
})
7574
resetDbChainMock()
7675
queueTableRows(member, [{ role: 'admin' }])
76+
mockIsOrgMemberUsageLimitTarget.mockResolvedValue(true)
7777
mockGetOrgMemberUsageForCurrentPeriod.mockResolvedValue(1) // $1 -> 200 credits
7878
mockGetOrgMemberUsageLimit.mockResolvedValue(2) // $2 -> 400 credits
7979
mockGetOrganizationSubscription.mockResolvedValue(null)
@@ -110,6 +110,16 @@ describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => {
110110
},
111111
})
112112
expect(mockGetOrgMemberUsageForCurrentPeriod).toHaveBeenCalledWith('org-1', 'user-2', null)
113+
expect(mockIsOrgMemberUsageLimitTarget).toHaveBeenCalledWith('org-1', 'user-2')
114+
})
115+
116+
it('returns 404 before reading a target outside the organization', async () => {
117+
mockIsOrgMemberUsageLimitTarget.mockResolvedValue(false)
118+
const res = await GET(getRequest(), context())
119+
expect(res.status).toBe(404)
120+
expect(mockGetOrgMemberUsageLimit).not.toHaveBeenCalled()
121+
expect(mockGetOrganizationSubscription).not.toHaveBeenCalled()
122+
expect(mockGetOrgMemberUsageForCurrentPeriod).not.toHaveBeenCalled()
113123
})
114124

115125
it('reuses the fetched org subscription for the usage window', async () => {
@@ -157,6 +167,7 @@ describe('PUT /api/organizations/[id]/members/[memberId]/usage-limit', () => {
157167
})
158168
resetDbChainMock()
159169
queueTableRows(member, [{ role: 'admin' }])
170+
mockIsOrgMemberUsageLimitTarget.mockResolvedValue(true)
160171
mockSetOrgMemberUsageLimit.mockResolvedValue(undefined)
161172
})
162173

@@ -193,6 +204,17 @@ describe('PUT /api/organizations/[id]/members/[memberId]/usage-limit', () => {
193204
expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', null, 'admin-1')
194205
})
195206

207+
it.each([400, null])(
208+
'rejects cap %s for a target outside the organization',
209+
async (creditLimit) => {
210+
mockIsOrgMemberUsageLimitTarget.mockResolvedValue(false)
211+
const res = await PUT(putRequest({ creditLimit }), context())
212+
expect(res.status).toBe(404)
213+
expect(mockSetOrgMemberUsageLimit).not.toHaveBeenCalled()
214+
expect(auditMock.recordAudit).not.toHaveBeenCalled()
215+
}
216+
)
217+
196218
it('rejects a negative credit limit with 400', async () => {
197219
const res = await PUT(putRequest({ creditLimit: -5 }), context())
198220
expect(res.status).toBe(400)

0 commit comments

Comments
 (0)