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: granular member role permissions based on resources #6046

Closed
wants to merge 2 commits into from
Closed
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
@@ -0,0 +1,19 @@
import { type MigrationExecutor } from '../pg-migrator';

export default {
name: '2024.11.21T00-00-00.member-rules-permissions.ts',
run: ({ sql }) => sql`
ALTER TABLE "organization_member_roles"
ALTER "scopes" DROP NOT NULL
, ADD COLUMN "permissions_groups" JSONB
;

CREATE TABLE "organization_member_role_assignments" (
"organization_id" UUID NOT NULL REFERENCES "organizations" ("id") ON DELETE CASCADE
, "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE
, "organization_member_role_id" UUID NOT NULL REFERENCES "organization_member_roles" ("id") ON DELETE CASCADE
, "resources" JSONB
PRIMARY KEY ("organization_id", "user_id", "organization_member_role_id")
);
`,
} satisfies MigrationExecutor;
1 change: 1 addition & 0 deletions packages/migrations/src/run-pg-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,5 +147,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri
await import('./actions/2024.11.12T00-00-00.supertokens-9.1'),
await import('./actions/2024.11.12T00-00-00.supertokens-9.2'),
await import('./actions/2024.11.12T00-00-00.supertokens-9.3'),
await import('./actions/2024.11.21T00-00-00.member-rules-permissions'),
],
});
10 changes: 9 additions & 1 deletion packages/services/api/src/modules/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createModule } from 'graphql-modules';
import { AuthManager } from './providers/auth-manager';
import { OrganizationAccess } from './providers/organization-access';
import { OrganizationMembers } from './providers/organization-members';
import { ProjectAccess } from './providers/project-access';
import { TargetAccess } from './providers/target-access';
import { UserManager } from './providers/user-manager';
Expand All @@ -12,5 +13,12 @@ export const authModule = createModule({
dirname: __dirname,
typeDefs,
resolvers,
providers: [AuthManager, UserManager, OrganizationAccess, ProjectAccess, TargetAccess],
providers: [
AuthManager,
UserManager,
OrganizationAccess,
ProjectAccess,
TargetAccess,
OrganizationMembers,
],
});
2 changes: 1 addition & 1 deletion packages/services/api/src/modules/auth/lib/authz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ type ActionDefinitionMap = {

type Actions = keyof typeof actionDefinitions;

type ActionStrings = Actions | '*';
export type ActionStrings = Actions | '*';

/** Unauthenticated session that is returned by default. */
class UnauthenticatedSession extends Session {
Expand Down
277 changes: 123 additions & 154 deletions packages/services/api/src/modules/auth/lib/supertokens-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,23 @@ import { isUUID } from '../../../shared/is-uuid';
import { Logger } from '../../shared/providers/logger';
import type { Storage } from '../../shared/providers/storage';
import {
OrganizationAccessScope,
ProjectAccessScope,
TargetAccessScope,
} from '../providers/scopes';
import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz';
OrganizationMembers,
OrganizationMembershipRoleAssignment,
} from '../providers/organization-members';
import { ActionStrings, AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz';

export class SuperTokensCookieBasedSession extends Session {
public superTokensUserId: string;
private organizationMembers: OrganizationMembers;
private storage: Storage;

constructor(
args: { superTokensUserId: string; email: string },
deps: { storage: Storage; logger: Logger },
deps: { organizationMembers: OrganizationMembers; storage: Storage; logger: Logger },
) {
super({ logger: deps.logger });
this.superTokensUserId = args.superTokensUserId;
this.organizationMembers = deps.organizationMembers;
this.storage = deps.storage;
}

Expand All @@ -32,17 +33,54 @@ export class SuperTokensCookieBasedSession extends Session {
): Promise<Array<AuthorizationPolicyStatement>> {
const user = await this.getViewer();

this.logger.debug(
'Loading policy statements for organization. (userId=%s, organizationId=%s)',
user.id,
organizationId,
);

if (!isUUID(organizationId)) {
this.logger.debug(
'Invalid organization ID provided. (userId=%s, organizationId=%s)',
user.id,
organizationId,
);

return [];
}

const member = await this.storage.getOrganizationMember({
this.logger.debug(
'Load organization membership for user. (userId=%s, organizationId=%s)',
user.id,
organizationId,
);

const organization = await this.storage.getOrganization({ organizationId });
const organizationMembership = await this.organizationMembers.findOrganizationMembership({
organization,
userId: user.id,
});

console.log(JSON.stringify(organizationMembership, null, 2));

if (!organizationMembership) {
this.logger.debug(
'No membership found, resolve empty policy statements. (userId=%s, organizationId=%s)',
user.id,
organizationId,
);

return [];
}

// owner of organization should have full right to do anything.
if (member?.isOwner) {
if (organizationMembership.isAdmin) {
this.logger.debug(
'User is organization owner, resolve admin access policy. (userId=%s, organizationId=%s)',
user.id,
organizationId,
);

return [
{
action: '*',
Expand All @@ -52,11 +90,76 @@ export class SuperTokensCookieBasedSession extends Session {
];
}

if (Array.isArray(member?.scopes)) {
return transformOrganizationMemberLegacyScopes({ organizationId, scopes: member.scopes });
this.logger.debug(
'Translate organization role assignments to policy statements. (userId=%s, organizationId=%s)',
user.id,
organizationId,
);

const policyStatements = this.translateAssignedRolesToAuthorizationPolicyStatements(
organizationId,
organizationMembership.assignedRoles,
);

return policyStatements;
}

private translateAssignedRolesToAuthorizationPolicyStatements(
organizationId: string,
organizationMembershipRoleAssignments: Array<OrganizationMembershipRoleAssignment>,
): Array<AuthorizationPolicyStatement> {
const policyStatements: Array<AuthorizationPolicyStatement> = [];

for (const assignedRole of organizationMembershipRoleAssignments) {
for (const permissionGroup of assignedRole.role.permissionGroups) {
const resources = assignedRole.resources[permissionGroup.resourceType];

if (!resources) {
// skip is assignment group is missing
continue;
}

const resourceIds: Array<string> = [];

switch (permissionGroup.resourceType) {
case 'organization':
case 'project':
case 'target': {
if (resources === '*') {
resourceIds.push(`hrn:${organizationId}:${permissionGroup.resourceType}/*`);
break;
}
resourceIds.push(
...resources.map(
resourceId => `hrn:${organizationId}:${permissionGroup.resourceType}/${resourceId}`,
),
);
break;
}
case 'service': {
if (resources === '*') {
resourceIds.push(`hrn:${organizationId}:target/*/service/*`);
break;
}
resourceIds.push(
...resources.map(resourceId => {
const [targetId, serviceName] = resourceId.split('/');
return `hrn:${organizationId}:${targetId}/service/${serviceName}`;
}),
);
break;
}
}

policyStatements.push({
action: permissionGroup.permissions as ActionStrings[],
effect: permissionGroup.effect,
resource: resourceIds,
});
}
}

return [];
return policyStatements;
}

public async getViewer(): Promise<User> {
Expand All @@ -78,11 +181,17 @@ export class SuperTokensCookieBasedSession extends Session {

export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCookieBasedSession> {
private logger: ServiceLogger;
private organizationMembers: OrganizationMembers;
private storage: Storage;

constructor(deps: { logger: ServiceLogger; storage: Storage }) {
constructor(deps: {
logger: ServiceLogger;
organizationMembers: OrganizationMembers;
storage: Storage;
}) {
super();
this.logger = deps.logger.child({ module: 'SuperTokensUserAuthNStrategy' });
this.organizationMembers = deps.organizationMembers;
this.storage = deps.storage;
}

Expand Down Expand Up @@ -172,8 +281,9 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
email: session.email,
},
{
storage: this.storage,
organizationMembers: this.organizationMembers,
logger: args.req.log,
storage: this.storage,
},
);
}
Expand All @@ -184,144 +294,3 @@ const SuperTokenAccessTokenModel = zod.object({
superTokensUserId: zod.string(),
email: zod.string(),
});

function transformOrganizationMemberLegacyScopes(args: {
organizationId: string;
scopes: Array<OrganizationAccessScope | ProjectAccessScope | TargetAccessScope>;
}) {
const policies: Array<AuthorizationPolicyStatement> = [];
for (const scope of args.scopes) {
switch (scope) {
case OrganizationAccessScope.READ: {
policies.push({
effect: 'allow',
action: [
'support:manageTickets',
'project:create',
'project:describe',
'organization:describe',
],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case OrganizationAccessScope.SETTINGS: {
policies.push({
effect: 'allow',
action: [
'organization:modifySlug',
'schemaLinting:modifyOrganizationRules',
'billing:describe',
'billing:update',
],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case OrganizationAccessScope.DELETE: {
policies.push({
effect: 'allow',
action: ['organization:delete'],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case OrganizationAccessScope.INTEGRATIONS: {
policies.push({
effect: 'allow',
action: ['oidc:modify', 'gitHubIntegration:modify', 'slackIntegration:modify'],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case OrganizationAccessScope.MEMBERS: {
policies.push({
effect: 'allow',
action: [
'member:manageInvites',
'member:removeMember',
'member:assignRole',
'member:modifyRole',
'member:describe',
],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case ProjectAccessScope.ALERTS: {
policies.push({
effect: 'allow',
action: ['alert:modify'],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case ProjectAccessScope.READ: {
policies.push({
effect: 'allow',
action: ['project:describe'],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case ProjectAccessScope.DELETE: {
policies.push({
effect: 'allow',
action: ['project:delete'],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case ProjectAccessScope.SETTINGS: {
policies.push({
effect: 'allow',
action: ['project:delete', 'project:modifySettings', 'schemaLinting:modifyProjectRules'],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case TargetAccessScope.READ: {
policies.push({
effect: 'allow',
action: ['appDeployment:describe', 'laboratory:describe', 'target:create'],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case TargetAccessScope.REGISTRY_WRITE: {
policies.push({
effect: 'allow',
action: ['schemaCheck:approve', 'schemaVersion:approve', 'laboratory:modify'],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case TargetAccessScope.TOKENS_WRITE: {
policies.push({
effect: 'allow',
action: ['targetAccessToken:modify', 'cdnAccessToken:modify'],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case TargetAccessScope.SETTINGS: {
policies.push({
effect: 'allow',
action: ['target:modifySettings'],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
case TargetAccessScope.DELETE: {
policies.push({
effect: 'allow',
action: ['target:delete'],
resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`],
});
break;
}
}
}

return policies;
}
Loading
Loading