Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: member role resource assignments #6354

Draft
wants to merge 2 commits into
base: feat-permission-member-roles
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@ export default {
ALTER "scopes" DROP NOT NULL
, ADD COLUMN "permissions" text[]
;

ALTER TABLE "organization_member"
ADD COLUMN "assigned_resources" JSONB
;
`,
} satisfies MigrationExecutor;
122 changes: 122 additions & 0 deletions packages/services/api/src/modules/organization/module.graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ export default gql`
organizationSlug: String!
userId: ID!
roleId: ID!
resources: MemberResourceAssignmentInput!
}

type AssignMemberRoleOk {
Expand All @@ -449,6 +450,7 @@ export default gql`
isOwner: Boolean!
canLeaveOrganization: Boolean!
role: MemberRole!
resourceAssignment: MemberResourceAssignment!
"""
Whether the viewer can remove this member from the organization.
"""
Expand All @@ -459,4 +461,124 @@ export default gql`
nodes: [Member!]!
total: Int!
}

input MemberAppDeploymentAssignmentInput {
appDeployment: String!
}

"""
@oneOf
"""
input MemberTargetAppDeploymentsAssignmentInput {
"""
Whether the permissions should apply for all app deployments within the target.
"""
allAppDeployments: Boolean
"""
Specific app deployments within the target for which the permissions should be applied.
"""
appDeployments: [MemberAppDeploymentAssignmentInput!]
}

input MemberServiceAssignmentInput {
serviceName: String!
}

"""
@oneOf
"""
input MemberTargetServicesAssignmentInput {
"""
Whether the permissions should apply for all services within the target.
"""
allServices: Boolean
"""
Specific services within the target for which the permissions should be applied.
"""
services: MemberServiceAssignmentInput
}

input MemberTargetAssignmentInput {
targetId: ID!
services: MemberTargetServicesAssignmentInput!
appDeployments: MemberTargetAppDeploymentsAssignmentInput!
}

"""
@oneOf
"""
input MemberProjectTargetsAssignmentInput {
"""
Whether the permissions should apply for all targets within the project.
"""
allTargets: Boolean
"""
Specific targets within the projects for which the permissions should be applied.
"""
targets: [MemberTargetAssignmentInput!]
}

input MemberProjectAssignmentInput {
projectId: ID!
targets: MemberProjectTargetsAssignmentInput!
}

"""
@oneOf
"""
input MemberResourceAssignmentInput {
"""
Whether the permissions should apply for all projects within the organization.
"""
allProjects: Boolean
"""
Specific projects within the organization for which the permissions should be applied.
"""
projects: [MemberProjectAssignmentInput!]
}

"""
@oneOf
"""
type MemberTargetServicesAssignment {
allServices: Boolean
services: [String!]
}

"""
@oneOf
"""
type MemberTargetAppDeploymentAssignment {
allAppDeployments: Boolean
appDeployments: [String!]
}

type MemberTargetAssignment {
targetId: ID!
target: Target!
services: MemberTargetServicesAssignment!
appDeployments: MemberTargetAppDeploymentAssignment!
}

"""
@oneOf
"""
type MemberProjectTargetsAssignment {
allTargets: Boolean
targets: [MemberTargetAssignment!]
}

type MemberProjectAssignment {
projectId: ID!
project: Project!
targets: MemberProjectTargetsAssignment!
}

"""
@oneOf
"""
type MemberResourceAssignment {
allProjects: Boolean
projects: [MemberProjectAssignment!]
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ const RawOrganizationMembershipModel = z.object({
* Resources that are assigned to the membership
* If no resources are defined the permissions of the role are applied to all resources within the organization.
*/
// TODO: introduce this value
// assignedResources: AssignedResourceModel.nullable().transform(value => value ?? '*'),
assignedResources: AssignedResourceModel.nullable().transform(value => value ?? '*'),
});

export type OrganizationMembershipRoleAssignment = {
role: OrganizationMemberRole;
/**
* Resource assignments as stored within the database.
* They are used for displaying the selection UI on the frontend.
*/
resources: ResourceAssignmentGroup;
/**
Expand Down Expand Up @@ -149,8 +149,7 @@ export class OrganizationMembers {
throw new Error('Could not resolve role.');
}

// TODO: use value read from database
const resources: ResourceAssignmentGroup = '*';
const resources: ResourceAssignmentGroup = record.assignedResources ?? '*';

organizationMembershipByUserId.set(record.userId, {
organizationId: organization.id,
Expand Down Expand Up @@ -249,12 +248,105 @@ export class OrganizationMembers {
const mapping = await this.resolveMemberships(organization, [membership]);
return mapping.get(membership.userId) ?? null;
}

/**
* This method translates the database stored member resource assignment to the GraphQL layer
* exposed resource assignment.
*
* Note: This currently by-passes access checks, granting the viewer read access to all resources
* within the organization.
*/
async resolveGraphQLMemberResourceAssignment(member: OrganizationMembership) {
if (member.assignedRole.resources === '*') {
return {
allProjects: true,
};
}

const projects = await this.storage.findProjectsByIds(
member.assignedRole.resources.map(project => project.id),
);

console.log(projects);

// if there is no project all the assignments do not longer exist.
const [firstProject] = projects.values();
if (!firstProject) {
return {
projects: [],
};
}

const filteredProjects = member.assignedRole.resources.filter(row => projects.get(row.id));

const targetAssignments = filteredProjects.flatMap(project =>
project.targets === '*' ? [] : project.targets,
);

const targets = await this.storage.findTargetsByIds(
firstProject.orgId,
targetAssignments.map(target => target.id),
);

return {
projects: filteredProjects
.map(projectAssignment => {
const project = projects.get(projectAssignment.id);
if (!project) {
return null;
}

return {
projectId: project.id,
project,
targets:
projectAssignment.targets === '*'
? { allTargets: true }
: {
targets: projectAssignment.targets
.map(targetAssignment => {
const target = targets.get(targetAssignment.id);
if (!target) return null;

return {
targetId: target.id,
target,
appDeployments:
targetAssignment.appDeployments === '*'
? { allAppDeployments: true }
: {
appDeployments: targetAssignment.appDeployments.map(
deployment => deployment.appName,
),
},
services:
targetAssignment.services === '*'
? { allServices: true }
: {
services: targetAssignment.services.map(
service => service.serviceName,
),
},
};
})
.filter(isSome),
},
};
})
.filter(isSome),
};
}
}

function isSome<T>(input: T | null): input is Exclude<T, null> {
return input != null;
}

const organizationMemberFields = (prefix = sql`"organization_member"`) => sql`
${prefix}."user_id" AS "userId"
, ${prefix}."role_id" AS "roleId"
, ${prefix}."connected_to_zendesk" AS "connectedToZendesk"
, ${prefix}."assigned_resources" AS "assignedResources"
`;

type OrganizationAssignment = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Storage } from '../../shared/providers/storage';
import { OrganizationManager } from '../providers/organization-manager';
import { OrganizationMembers } from '../providers/organization-members';
import type { MemberResolvers } from './../../../__generated__/types';

export const Member: MemberResolvers = {
Expand Down Expand Up @@ -34,4 +35,7 @@ export const Member: MemberResolvers = {
}
return user;
},
resourceAssignment: async (member, _arg, { injector }) => {
return injector.get(OrganizationMembers).resolveGraphQLMemberResourceAssignment(member);
},
};
4 changes: 4 additions & 0 deletions packages/services/api/src/modules/shared/providers/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ export interface Storage {

getProjects(_: OrganizationSelector): Promise<Project[] | never>;

findProjectsByIds(projectIds: Array<string>): Promise<Map<string, Project>>;

createProject(_: Pick<Project, 'type'> & { slug: string } & OrganizationSelector): Promise<
| {
ok: true;
Expand Down Expand Up @@ -307,6 +309,8 @@ export interface Storage {

getTargets(_: ProjectSelector): Promise<readonly Target[]>;

findTargetsByIds(organizationId: string, targetIds: Array<string>): Promise<Map<string, Target>>;

getTargetIdsOfOrganization(_: OrganizationSelector): Promise<readonly string[]>;
getTargetIdsOfProject(_: ProjectSelector): Promise<readonly string[]>;
getTargetSettings(_: TargetSelector): Promise<TargetSettings | never>;
Expand Down
36 changes: 36 additions & 0 deletions packages/services/storage/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
Project,
Schema,
Storage,
Target,
TargetSettings,
} from '@hive/api';
import { context, SpanKind, SpanStatusCode, trace } from '@hive/service-common';
Expand Down Expand Up @@ -1408,6 +1409,18 @@ export async function createStorage(

return result.rows.map(transformProject);
},
async findProjectsByIds(ids) {
const result = await pool.query<Slonik<projects>>(
sql`/* findProjectsByIds */ SELECT * FROM projects WHERE id = ANY(${sql.array(ids, 'uuid')}) AND type != 'CUSTOM'`,
);

const map = new Map<string, Project>();
result.rows.forEach(row => {
const project = transformProject(row);
map.set(project.id, project);
});
return map;
},
async updateProjectSlug({ slug, organizationId: organization, projectId: project }) {
return pool.transaction(async t => {
const projectSlugExists = await t.exists(
Expand Down Expand Up @@ -1699,6 +1712,29 @@ export async function createStorage(
orgId: organization,
}));
},
async findTargetsByIds(organizationId, targetIds) {
const map = new Map<string, Target>();

if (targetIds.length === 0) {
return map;
}

const results = await pool.query<unknown>(sql`/* getTargets */
SELECT
${targetSQLFields}
FROM
"targets"
WHERE
"id" = ANY(${sql.array(targetIds, 'uuid')})
`);

results.rows.forEach(r => ({
...TargetModel.parse(r),
orgId: organizationId,
}));

return map;
},
async getTargetIdsOfOrganization({ organizationId: organization }) {
const results = await pool.query<Slonik<Pick<targets, 'id'>>>(
sql`/* getTargetIdsOfOrganization */
Expand Down
Loading
Loading