Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
n1ru4l committed Jan 20, 2025
1 parent 5799a10 commit 22cfdd7
Show file tree
Hide file tree
Showing 6 changed files with 264 additions and 4 deletions.
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,103 @@ 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),
);

// 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(isNone),
},
};
})
.filter(isNone),
};
}
}

function isNone<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

0 comments on commit 22cfdd7

Please sign in to comment.