Skip to content

Commit

Permalink
feat: tag based permissions (#4853)
Browse files Browse the repository at this point in the history
  • Loading branch information
kyle-ssg authored Dec 4, 2024
1 parent 05ab7cf commit 7c4e2ff
Show file tree
Hide file tree
Showing 42 changed files with 2,316 additions and 1,939 deletions.
45 changes: 26 additions & 19 deletions docs/docs/system-administration/rbac.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ Assigning roles to groups has several benefits over assigning permissions direct

Permissions can be assigned at four levels: user group, organisation, project, and environment.

## Tagged Permissions

When creating a role, some permissions allow you to grant access when features have specific tags. For example, you can
configure a role to create change requests only for features tagged with "marketing".

### User group

| Permission | Ability |
Expand All @@ -149,25 +154,27 @@ Permissions can be assigned at four levels: user group, organisation, project, a

### Project

| Permission | Ability |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Administrator | Grants full read and write access to all environments, features and segments. |
| View Project | Allows viewing this project. The project is hidden from users without this permission. |
| Create Environment | Allows creating new environments in this project. Users are automatically granted Administrator permissions on any environments they create. |
| Create Feature | Allows creating new features in all environments. |
| Delete Feature | Allows deleting features from all environments. |
| Manage Segments | Grants write access to segments in this project. |
| View audit log | Allows viewing all audit log entries for this project. |
### Project

| Permission | Ability | Supports Tags |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| Administrator | Grants full read and write access to all environments, features, and segments. | |
| View Project | Allows viewing this project. The project is hidden from users without this permission. | |
| Create Environment | Allows creating new environments in this project. Users are automatically granted Administrator permissions on any environments they create. | |
| Create Feature | Allows creating new features in all environments. | |
| Delete Feature | Allows deleting features from all environments. | Yes |
| Manage Segments | Grants write access to segments in this project. | |
| View audit log | Allows viewing all audit log entries for this project. | |

### Environment

| Permission | Ability |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| Administrator | Grants full read and write access to all feature states, overrides, identities and change requests in this environment. |
| View Environment | Allows viewing this environment. The environment is hidden from users without this permission. |
| Update Feature State | Allows updating updating any feature state or values in this environment. |
| Manage Identities | Grants read and write access to identities in this environment. |
| Manage Segment Overrides | Grants write access to segment overrides in this environment. |
| Create Change Request | Allows creating change requests for features in this environment. |
| Approve Change Request | Allows approving or denying change requests in this environment. |
| View Identities | Grants read-only access to identities in this environment. |
| Permission | Ability | Supports Tags |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------- |
| Administrator | Grants full read and write access to all feature states, overrides, identities, and change requests in this environment. | |
| View Environment | Allows viewing this environment. The environment is hidden from users without this permission. | |
| Update Feature State | Allows updating any feature state or values in this environment. | Yes |
| Manage Identities | Grants read and write access to identities in this environment. | |
| Manage Segment Overrides | Grants write access to segment overrides in this environment. | |
| Create Change Request | Allows creating change requests for features in this environment. | Yes |
| Approve Change Request | Allows approving or denying change requests in this environment. | Yes |
| View Identities | Grants read-only access to identities in this environment. | |
4 changes: 4 additions & 0 deletions frontend/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ app.get('/config/project-overrides', (req, res) => {
name: 'githubAppURL',
value: process.env.GITHUB_APP_URL,
},
{
name: 'e2eToken',
value: process.env.E2E_TEST_TOKEN || '',
},
]
let output = values.map(getVariable).join('')
let dynatrace = ''
Expand Down
33 changes: 27 additions & 6 deletions frontend/common/providers/Permission.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import React, { FC, ReactNode } from 'react'
import React, { FC, ReactNode, useMemo } from 'react'
import { useGetPermissionQuery } from 'common/services/usePermission'
import { PermissionLevel } from 'common/types/requests'
import AccountStore from 'common/stores/account-store' // we need this to make JSX compile
import AccountStore from 'common/stores/account-store'
import intersection from 'lodash/intersection'
import { add } from 'ionicons/icons';
import { cloneDeep } from 'lodash'; // we need this to make JSX compile

type PermissionType = {
id: any
permission: string
tags?: number[]
level: PermissionLevel
children: (data: { permission: boolean; isLoading: boolean }) => ReactNode
}
Expand All @@ -14,11 +18,26 @@ export const useHasPermission = ({
id,
level,
permission,
tags,
}: Omit<PermissionType, 'children'>) => {
const { data, isLoading, isSuccess } = useGetPermissionQuery(
{ id: `${id}`, level },
{ skip: !id || !level },
)
const {
data: permissionsData,
isLoading,
isSuccess,
} = useGetPermissionQuery({ id: `${id}`, level }, { skip: !id || !level })
const data = useMemo(() => {
if (!tags?.length || !permissionsData?.tag_based_permissions)
return permissionsData
const addedPermissions = cloneDeep(permissionsData)
permissionsData.tag_based_permissions.forEach((tagBasedPermission) => {
if (intersection(tagBasedPermission.tags, tags).length) {
tagBasedPermission.permissions.forEach((key) => {
addedPermissions[key] = true
})
}
})
return addedPermissions
}, [permissionsData, tags])
const hasPermission = !!data?.[permission] || !!data?.ADMIN
return {
isLoading,
Expand All @@ -32,11 +51,13 @@ const Permission: FC<PermissionType> = ({
id,
level,
permission,
tags,
}) => {
const { isLoading, permission: hasPermission } = useHasPermission({
id,
level,
permission,
tags,
})
return (
<>
Expand Down
17 changes: 13 additions & 4 deletions frontend/common/services/usePermission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,25 @@ export const permissionService = service
query: ({ id, level }: Req['getPermission']) => ({
url: `${level}s/${id}/my-permissions/`,
}),
transformResponse(baseQueryReturnValue: {
admin: boolean
permissions: string[]
}) {
transformResponse(
baseQueryReturnValue: {
admin: boolean
permissions: string[]
tag_based_permissions?: Res['permission']['tag_based_permissions']
},
_,
) {
const res: Res['permission'] = {
ADMIN: baseQueryReturnValue.admin,
}
if (baseQueryReturnValue.tag_based_permissions) {
res.tag_based_permissions =
baseQueryReturnValue.tag_based_permissions
}
baseQueryReturnValue.permissions.forEach((v) => {
res[v] = true
})

return res
},
}),
Expand Down
2 changes: 1 addition & 1 deletion frontend/common/services/useProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const projectService = service
query: (data) => ({
url: `projects/?organisation=${data.organisationId}`,
}),
transformResponse: (res) => sortBy(res, 'name'),
transformResponse: (res) => sortBy(res, (v) => v.name.toLowerCase()),
}),
// END OF ENDPOINTS
}),
Expand Down
6 changes: 5 additions & 1 deletion frontend/common/services/useRole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const roleService = service
}),
}),
getRole: builder.query<Res['role'], Req['getRole']>({
providesTags: (res) => [{ id: res?.id, type: 'Role' }],
query: (query: Req['getRole']) => ({
url: `organisations/${query.organisation_id}/roles/${query.role_id}/`,
}),
Expand All @@ -33,7 +34,10 @@ export const roleService = service
}),
}),
updateRole: builder.mutation<Res['roles'], Req['updateRole']>({
invalidatesTags: (res) => [{ id: 'LIST', type: 'Role' }],
invalidatesTags: (res, _, req) => [
{ id: 'LIST', type: 'Role' },
{ id: req.role_id, type: 'Role' },
],
query: (query: Req['updateRole']) => ({
body: query.body,
method: 'PUT',
Expand Down
10 changes: 2 additions & 8 deletions frontend/common/services/useRolePermission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,12 @@ export async function getRoleProjectPermissions(
typeof rolePermissionService.endpoints.getRoleProjectPermissions.initiate
>[1],
) {
store.dispatch(
return store.dispatch(
rolePermissionService.endpoints.getRoleProjectPermissions.initiate(
data,
options,
),
)
return Promise.all(
store.dispatch(rolePermissionService.util.getRunningQueriesThunk()),
)
}

export async function getRoleEnvironmentPermissions(
Expand All @@ -139,15 +136,12 @@ export async function getRoleEnvironmentPermissions(
typeof rolePermissionService.endpoints.getRoleEnvironmentPermissions.initiate
>[1],
) {
store.dispatch(
return store.dispatch(
rolePermissionService.endpoints.getRoleEnvironmentPermissions.initiate(
data,
options,
),
)
return Promise.all(
store.dispatch(rolePermissionService.util.getRunningQueriesThunk()),
)
}

export async function createRolePermissions(
Expand Down
3 changes: 3 additions & 0 deletions frontend/common/stores/account-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import Constants from 'common/constants'
import dataRelay from 'data-relay'
import { sortBy } from 'lodash'
import Project from 'common/project'
import { getStore } from 'common/store'
import { service } from "common/service";

const controller = {
acceptInvite: (id) => {
Expand Down Expand Up @@ -341,6 +343,7 @@ const controller = {
API.reset().finally(() => {
store.model = user
store.organisation = null
getStore().dispatch(service.util.resetApiState())
store.trigger('logout')
})
})
Expand Down
6 changes: 5 additions & 1 deletion frontend/common/stores/organisation-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ const controller = {
)
: ['Development', 'Production']
data
.post(`${Project.api}projects/`, { name, organisation: store.id })
.post(
`${Project.api}projects/`,
{ name, organisation: store.id },
E2E ? { 'X-E2E-Test-Auth-Token': Project.e2eToken } : {},
)
.then((project) => {
Promise.all(
defaultEnvironmentNames.map((envName) => {
Expand Down
6 changes: 4 additions & 2 deletions frontend/common/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
UserGroup,
AttributeName,
Identity,
Role,
RolePermission,
} from './responses'

export type PagedRequest<T> = T & {
Expand Down Expand Up @@ -158,7 +160,7 @@ export type Req = {
updateRole: {
organisation_id: number
role_id: number
body: { description: string | null; name: string }
body: Role
}
deleteRole: { organisation_id: number; role_id: number }
getRolePermissionEnvironment: {
Expand All @@ -179,7 +181,7 @@ export type Req = {
level: PermissionLevel
body: {
admin?: boolean
permissions: string[]
permissions: RolePermission['permissions']
project: number
environment: number
}
Expand Down
14 changes: 11 additions & 3 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,12 @@ export type UserPermission = {
id: number
role?: number
}

export type RolePermission = Omit<UserPermission, 'permissions'> & {
permissions: { permission_key: string; tags: number[] }[]
}
export type GroupPermission = Omit<UserPermission, 'user'> & {
group: UserGroup
group: UserGroupSummary
}

export type AuditLogItem = {
Expand Down Expand Up @@ -328,6 +332,7 @@ export type Identity = {
export type AvailablePermission = {
key: string
description: string
supports_tag: boolean
}

export type APIKey = {
Expand Down Expand Up @@ -657,7 +662,10 @@ export type Res = {
}
identity: { id: string } //todo: we don't consider this until we migrate identity-store
identities: EdgePagedResponse<Identity>
permission: Record<string, boolean>
permission: Record<string, boolean> & {
ADMIN: boolean
tag_based_permissions?: { permissions: string[]; tags: number[] }[]
}
availablePermissions: AvailablePermission[]
tag: Tag
tags: Tag[]
Expand Down Expand Up @@ -695,7 +703,7 @@ export type Res = {
versionFeatureState: FeatureState[]
role: Role
roles: PagedResponse<Role>
rolePermission: PagedResponse<UserPermission>
rolePermission: PagedResponse<RolePermission>
projectFlags: PagedResponse<ProjectFlag>
projectFlag: ProjectFlag
identityFeatureStatesAll: IdentityFeatureState[]
Expand Down
30 changes: 0 additions & 30 deletions frontend/common/utils/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,36 +341,6 @@ const Utils = Object.assign({}, require('./base/_utils'), {
return `/organisation/${orgId}/projects`
},

getPermissionList(
isAdmin: boolean,
permissions: string[] | undefined | null,
numberToTruncate = 3,
): {
items: string[]
truncatedItems: string[]
} {
if (isAdmin) {
return {
items: ['Administrator'],
truncatedItems: [],
}
}
if (!permissions) return { items: [], truncatedItems: [] }

const items =
permissions && permissions.length
? permissions
.slice(0, numberToTruncate)
.map((item) => `${Format.enumeration.get(item)}`)
: []

return {
items,
truncatedItems: (permissions || [])
.slice(numberToTruncate)
.map((item) => `${Format.enumeration.get(item)}`),
}
},
getPlanName: (plan: string) => {
if (plan && plan.includes('free')) {
return planNames.free
Expand Down
3 changes: 3 additions & 0 deletions frontend/web/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ const App = class extends Component {
>
<NavLink
id='org-settings-link'
data-test='org-settings-link'
activeClassName='active'
className={classNames(
'breadcrumb-link',
Expand Down Expand Up @@ -571,6 +572,7 @@ const App = class extends Component {
icon={<AuditLogIcon />}
id='audit-log-link'
to={`/project/${projectId}/audit-log`}
data-test='audit-log-link'
>
Audit Log
</NavSubLink>
Expand Down Expand Up @@ -661,6 +663,7 @@ const App = class extends Component {
<NavSubLink
icon={<SettingsIcon />}
id='org-settings-link'
data-test='org-settings-link'
to={`/organisation/${
AccountStore.getOrganisation().id
}/settings`}
Expand Down
Loading

0 comments on commit 7c4e2ff

Please sign in to comment.