Skip to content

Commit 717b5aa

Browse files
n1ru4ljdolle
andauthored
feat: email invite and default oidc resources assignments (#7252)
Co-authored-by: jdolle <[email protected]>
1 parent 73f1ea0 commit 717b5aa

33 files changed

+1386
-229
lines changed

.changeset/common-crabs-grow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'hive': minor
3+
---
4+
5+
Support selecting resources when inviting a user via email.

.changeset/strong-carrots-sniff.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'hive': minor
3+
---
4+
5+
Support assigning default resources for new OIDC members.

integration-tests/testkit/seed.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -889,7 +889,11 @@ export function initSeed() {
889889
},
890890
};
891891
},
892-
async inviteAndJoinMember(inviteToken: string = ownerToken) {
892+
async inviteAndJoinMember(
893+
inviteToken: string = ownerToken,
894+
memberRoleId: string | undefined = undefined,
895+
resources: GraphQLSchema.ResourceAssignmentInput | undefined = undefined,
896+
) {
893897
const memberEmail = userEmail(generateUnique());
894898
const memberToken = await authenticate(memberEmail).then(r => r.access_token);
895899

@@ -901,6 +905,8 @@ export function initSeed() {
901905
},
902906
},
903907
email: memberEmail,
908+
memberRoleId,
909+
resources,
904910
},
905911
inviteToken,
906912
).then(r => r.expectNoGraphQLErrors());
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { graphql } from 'testkit/gql';
2+
import { ResourceAssignmentModeType } from 'testkit/gql/graphql';
3+
import { execute } from 'testkit/graphql';
4+
import { initSeed } from 'testkit/seed';
5+
6+
const AssignedResourcesSpec_CreateOIDCIntegrationMutation = graphql(`
7+
mutation AssignedResourcesSpec_CreateOIDCIntegrationMutation(
8+
$input: CreateOIDCIntegrationInput!
9+
) {
10+
createOIDCIntegration(input: $input) {
11+
ok {
12+
createdOIDCIntegration {
13+
id
14+
defaultResourceAssignment {
15+
mode
16+
}
17+
}
18+
}
19+
}
20+
}
21+
`);
22+
23+
const AssignedResourcesSpec_ReadDefaultTest = graphql(`
24+
query AssignedResourcesSpec_ReadDefaultTest($organizationSlug: String!) {
25+
organization(reference: { bySelector: { organizationSlug: $organizationSlug } }) {
26+
id
27+
oidcIntegration {
28+
defaultResourceAssignment {
29+
mode
30+
projects {
31+
project {
32+
id
33+
slug
34+
}
35+
targets {
36+
mode
37+
targets {
38+
target {
39+
id
40+
slug
41+
}
42+
services {
43+
mode
44+
services
45+
}
46+
appDeployments {
47+
mode
48+
appDeployments
49+
}
50+
}
51+
}
52+
}
53+
}
54+
}
55+
}
56+
}
57+
`);
58+
59+
const AssignedResourcesSpec_UpdateDefaultMutation = graphql(`
60+
mutation AssignedResourcesSpec_UpdateDefaultMutation(
61+
$input: UpdateOIDCDefaultResourceAssignmentInput!
62+
) {
63+
updateOIDCDefaultResourceAssignment(input: $input) {
64+
ok {
65+
updatedOIDCIntegration {
66+
id
67+
defaultResourceAssignment {
68+
mode
69+
projects {
70+
project {
71+
id
72+
slug
73+
}
74+
targets {
75+
mode
76+
targets {
77+
target {
78+
id
79+
slug
80+
}
81+
services {
82+
mode
83+
services
84+
}
85+
appDeployments {
86+
mode
87+
appDeployments
88+
}
89+
}
90+
}
91+
}
92+
}
93+
}
94+
}
95+
error {
96+
message
97+
}
98+
}
99+
}
100+
`);
101+
102+
async function setup() {
103+
const { ownerToken, createOrg } = await initSeed().createOwner();
104+
const { organization, createOrganizationAccessToken } = await createOrg();
105+
106+
const result = await execute({
107+
document: AssignedResourcesSpec_CreateOIDCIntegrationMutation,
108+
variables: {
109+
input: {
110+
organizationId: organization.id,
111+
clientId: 'foo',
112+
clientSecret: 'foofoofoofoo',
113+
tokenEndpoint: 'http://localhost:8888/oauth/token',
114+
userinfoEndpoint: 'http://localhost:8888/oauth/userinfo',
115+
authorizationEndpoint: 'http://localhost:8888/oauth/authorize',
116+
},
117+
},
118+
authToken: ownerToken,
119+
}).then(r => r.expectNoGraphQLErrors());
120+
121+
// no default exists at creation
122+
expect(result.createOIDCIntegration.ok?.createdOIDCIntegration.defaultResourceAssignment).toBe(
123+
null,
124+
);
125+
return {
126+
organization,
127+
ownerToken,
128+
oidcIntegrationId: result.createOIDCIntegration.ok?.createdOIDCIntegration.id!,
129+
createOrganizationAccessToken,
130+
};
131+
}
132+
133+
describe('read OIDC', () => {
134+
describe('permissions="organization:integrations"', () => {
135+
test.concurrent('success', async ({ expect }) => {
136+
const { organization, ownerToken, oidcIntegrationId } = await setup();
137+
138+
await execute({
139+
document: AssignedResourcesSpec_UpdateDefaultMutation,
140+
variables: {
141+
input: {
142+
oidcIntegrationId,
143+
resources: {
144+
mode: ResourceAssignmentModeType.All,
145+
},
146+
},
147+
},
148+
authToken: ownerToken,
149+
}).then(r => r.expectNoGraphQLErrors());
150+
151+
const read = await execute({
152+
document: AssignedResourcesSpec_ReadDefaultTest,
153+
variables: {
154+
organizationSlug: organization.slug,
155+
},
156+
authToken: ownerToken,
157+
}).then(r => r.expectNoGraphQLErrors());
158+
159+
expect(read).toEqual({
160+
organization: {
161+
id: expect.stringMatching('.+'),
162+
oidcIntegration: {
163+
defaultResourceAssignment: {
164+
mode: 'ALL',
165+
projects: null,
166+
},
167+
},
168+
},
169+
});
170+
});
171+
});
172+
173+
describe('permissions missing "organization:integrations"', () => {
174+
test.concurrent('fail', async ({ expect }) => {
175+
const { organization, ownerToken, oidcIntegrationId, createOrganizationAccessToken } =
176+
await setup();
177+
const { privateAccessKey: readToken } = await createOrganizationAccessToken({
178+
permissions: ['organization:read'],
179+
resources: { mode: ResourceAssignmentModeType.All },
180+
});
181+
182+
await execute({
183+
document: AssignedResourcesSpec_UpdateDefaultMutation,
184+
variables: {
185+
input: {
186+
oidcIntegrationId,
187+
resources: {
188+
mode: ResourceAssignmentModeType.All,
189+
},
190+
},
191+
},
192+
authToken: ownerToken,
193+
}).then(r => r.expectNoGraphQLErrors());
194+
195+
const errors = await execute({
196+
document: AssignedResourcesSpec_ReadDefaultTest,
197+
variables: {
198+
organizationSlug: organization.slug,
199+
},
200+
authToken: readToken,
201+
}).then(r => r.expectGraphQLErrors());
202+
expect(errors).toHaveLength(1);
203+
expect(errors).toMatchObject([
204+
{
205+
extensions: {
206+
code: 'UNAUTHORISED',
207+
},
208+
message: `No access (reason: "Missing permission for performing 'organization:describe' on resource")`,
209+
path: ['organization'],
210+
},
211+
]);
212+
});
213+
});
214+
});
215+
216+
describe('update OIDC default assigned resources', () => {
217+
describe('permissions="oidc:modify"', () => {
218+
test.concurrent('success', async ({ expect }) => {
219+
const { ownerToken, oidcIntegrationId } = await setup();
220+
221+
const update = await execute({
222+
document: AssignedResourcesSpec_UpdateDefaultMutation,
223+
variables: {
224+
input: {
225+
oidcIntegrationId,
226+
resources: {
227+
mode: ResourceAssignmentModeType.All,
228+
},
229+
},
230+
},
231+
authToken: ownerToken,
232+
}).then(r => r.expectNoGraphQLErrors());
233+
234+
expect(update).toEqual({
235+
updateOIDCDefaultResourceAssignment: {
236+
error: null,
237+
ok: {
238+
updatedOIDCIntegration: {
239+
defaultResourceAssignment: {
240+
mode: 'ALL',
241+
projects: null,
242+
},
243+
id: expect.stringMatching('.+'),
244+
},
245+
},
246+
},
247+
});
248+
});
249+
});
250+
251+
describe('permissions missing "oidc:modify"', () => {
252+
test.concurrent('fails', async ({ expect }) => {
253+
const { createOrganizationAccessToken, ownerToken, oidcIntegrationId } = await setup();
254+
255+
const { privateAccessKey: accessToken } = await createOrganizationAccessToken({
256+
permissions: ['organization:read'],
257+
resources: {
258+
mode: ResourceAssignmentModeType.All,
259+
},
260+
});
261+
262+
const errors = await execute({
263+
document: AssignedResourcesSpec_UpdateDefaultMutation,
264+
variables: {
265+
input: {
266+
oidcIntegrationId,
267+
resources: {
268+
mode: ResourceAssignmentModeType.All,
269+
},
270+
},
271+
},
272+
authToken: accessToken,
273+
}).then(r => r.expectGraphQLErrors());
274+
expect(errors).toHaveLength(1);
275+
expect(errors).toMatchObject([
276+
{
277+
extensions: {
278+
code: 'UNAUTHORISED',
279+
},
280+
message: `No access (reason: "Missing permission for performing 'oidc:modify' on resource")`,
281+
path: ['updateOIDCDefaultResourceAssignment'],
282+
},
283+
]);
284+
});
285+
});
286+
});

integration-tests/tests/api/organization/members.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { graphql } from 'testkit/gql';
2+
import { ResourceAssignmentModeType } from 'testkit/gql/graphql';
23
import { execute } from 'testkit/graphql';
34
import { history } from '../../../testkit/emails';
45
import { initSeed } from '../../../testkit/seed';
@@ -141,6 +142,38 @@ test.concurrent('can not invite with role not existing in organization', async (
141142
});
142143
});
143144

145+
test.concurrent('invite user with assigned resouces', async ({ expect }) => {
146+
const seed = initSeed();
147+
const owner = await seed.createOwner();
148+
const org = await owner.createOrg();
149+
const { project: project1 } = await org.createProject();
150+
// we just create this to make sure it does not show up :)
151+
const { project: _project2 } = await org.createProject();
152+
const { project: project3 } = await org.createProject();
153+
154+
const m = await org.inviteAndJoinMember();
155+
const role = await m.createMemberRole(['organization:describe', 'project:describe']);
156+
157+
const member = await org.inviteAndJoinMember(undefined, role.id, {
158+
mode: ResourceAssignmentModeType.Granular,
159+
projects: [
160+
{
161+
projectId: project1.id,
162+
targets: { mode: ResourceAssignmentModeType.Granular, targets: [] },
163+
},
164+
{
165+
projectId: project3.id,
166+
targets: { mode: ResourceAssignmentModeType.Granular, targets: [] },
167+
},
168+
],
169+
});
170+
171+
const result = await org.projects(member.memberToken);
172+
expect(result).toHaveLength(2);
173+
expect(result[0].id).toEqual(project3.id);
174+
expect(result[1].id).toEqual(project1.id);
175+
});
176+
144177
test.concurrent(
145178
'cannot join organization twice using the same invitation code',
146179
async ({ expect }) => {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { type MigrationExecutor } from '../pg-migrator';
2+
3+
export default {
4+
name: '2025.11.12T00-00-00.granular-oidc-role-permissions.ts',
5+
run: ({ sql }) => sql`
6+
ALTER TABLE "oidc_integrations"
7+
ADD COLUMN "default_assigned_resources" JSONB
8+
;
9+
10+
ALTER TABLE "organization_invitations"
11+
ADD COLUMN "assigned_resources" JSONB
12+
;
13+
`,
14+
} satisfies MigrationExecutor;

packages/migrations/src/run-pg-migrations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,5 +169,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri
169169
await import('./actions/2025.05.28T00-00-00.schema-log-by-ids'),
170170
await import('./actions/2025.10.16T00-00-00.schema-log-by-commit-ordered'),
171171
await import('./actions/2025.10.17T00-00-00.project-access-tokens'),
172+
await import('./actions/2025.11.12T00-00-00.granular-oidc-role-permissions'),
172173
],
173174
});

0 commit comments

Comments
 (0)