diff --git a/src/authz.test.ts b/src/authz.test.ts index 4046ed02d..4422230b3 100644 --- a/src/authz.test.ts +++ b/src/authz.test.ts @@ -123,14 +123,14 @@ describe('Schema collection wise permissions', () => { test('A team can doSomething', () => { const authz = new Authz(spec).init(sessionTeam) sessionTeam.authz = { teamA: { deniedAttributes: { Team: ['a', 'b'] } } } - expect(() => authz.hasSelfService('teamA', 'Team', 'doSomething')).not.toThrow() + expect(() => authz.hasSelfService('teamA', 'doSomething')).not.toThrow() sessionTeam.authz = {} }) test('A team cannot doSomething', () => { const authz = new Authz(spec).init(sessionTeam) sessionTeam.authz = { teamA: { deniedAttributes: { Team: ['a', 'b', 'doSomething'] } } } - expect(() => authz.hasSelfService('teamA', 'Team', 'doSomething')).not.toThrow() + expect(() => authz.hasSelfService('teamA', 'doSomething')).not.toThrow() sessionTeam.authz = {} }) }) diff --git a/src/authz.ts b/src/authz.ts index 900a8aecc..8ec6105c4 100644 --- a/src/authz.ts +++ b/src/authz.ts @@ -2,16 +2,7 @@ import { Ability, Subject, subject } from '@casl/ability' import Debug from 'debug' import { each, forIn, get, isEmpty, isEqual, omit, set } from 'lodash' -import { - Acl, - AclAction, - OpenAPIDoc, - PermissionSchema, - Schema, - SessionUser, - TeamAuthz, - UserAuthz, -} from 'src/otomi-models' +import { Acl, AclAction, OpenAPIDoc, Schema, SessionUser, TeamAuthz, UserAuthz } from 'src/otomi-models' import OtomiStack from 'src/otomi-stack' import { extract, flattenObject } from 'src/utils' @@ -72,9 +63,6 @@ export function isValidAuthzSpec(apiDoc: OpenAPIDoc): boolean { const { schemas } = apiDoc.components forIn(schemas, (schema: Schema, schemaName: string) => { debug(`loading rules for ${schemaName} schema`) - // @ts-ignore - // eslint-disable-next-line no-param-reassign - if (schema.type === 'array') { if (schema['x-acl']) { err.concat( @@ -128,7 +116,6 @@ export const loadSpecRules = (apiDoc: OpenAPIDoc): any => { const { schemas } = apiDoc.components Object.keys(schemas).forEach((schemaName: string) => { - // debug(`loading rules for ${schemaName} schema`) const schema: Schema = schemas[schemaName] if (schema.type === 'array') return @@ -146,9 +133,8 @@ export const loadSpecRules = (apiDoc: OpenAPIDoc): any => { export default class Authz { user: SessionUser - specRules: Record - + // TODO: replace Ability as it's deprecated rbac: Ability constructor(apiDoc: OpenAPIDoc) { @@ -189,10 +175,7 @@ export default class Authz { // actions like *-any imply that * is also allowed, so exclude those from inversion const normalized = _actions.map((a) => (a.includes('-any') ? a.slice(0, -4) : a)) allowedAttributeCrudActions - .filter((a) => { - const cond = !(normalized.includes(a) || _actions.includes(`${a}-any`)) - return cond - }) + .filter((a) => !(normalized.includes(a) || _actions.includes(`${a}-any`))) .forEach(createRule(schemaName, prop, true)) if (obj.properties) createRules(`${schemaName}.${prop}`, obj) }) @@ -226,19 +209,18 @@ export default class Authz { // also check if we are denied by lack of self service const deniedSelfServiceAttributes = get( this.user.authz, - `${teamId}.deniedAttributes.${schemaName.toLowerCase()}`, + `${teamId}.deniedAttributes.teamMembers`, [], ) as Array - // the two above denied lists should be mutually exclusive, because a schema design should not - // have have both self service as well as acl set for the same property, so we can merge the result + // merge denied attributes from both role-based and self-service restrictions const deniedAttributes = [...deniedRoleAttributes, ...deniedSelfServiceAttributes] deniedAttributes.forEach((path) => { const val = get(body, path) const origVal = get(dataOrig, path) - // undefined value expected for forbidden props, just put back before save + // undefined value expected for forbidden props, so put back original before save if (val === undefined) set(body, path, origVal) - // value provided which shouldn't happen + // if a value is provided which is not allowed, mark it as violated else if (!isEqual(val, origVal)) violatedAttributes.push(path) }) return violatedAttributes @@ -260,32 +242,46 @@ export default class Authz { return body.length !== undefined ? ret : ret[0] } - hasSelfService = (teamId: string, schema, attribute: string) => { - const deniedAttributes = get(this.user.authz, `${teamId}.deniedAttributes.${schema}`, []) as Array - if (deniedAttributes.includes(attribute)) return false - return true + hasSelfService = (teamId: string, attribute: string): boolean => { + const deniedAttributes = get(this.user.authz, `${teamId}.deniedAttributes.teamMembers`, []) as Array + return !deniedAttributes.includes(attribute) } } -export const getTeamSelfServiceAuthz = ( - teams: Array, - schema: PermissionSchema, - otomi: OtomiStack, -): UserAuthz => { +export const getTeamSelfServiceAuthz = (teams: Array, otomi: OtomiStack): UserAuthz => { const permissionMap: UserAuthz = {} teams.forEach((teamId) => { - const authz: TeamAuthz = {} as TeamAuthz - Object.keys(schema.properties).forEach((propName) => { - const possiblePermissions = schema.properties[propName].items.enum - set(authz, `deniedAttributes.${propName}`, []) - authz.deniedAttributes[propName] = possiblePermissions.filter((name) => { - const flags = get(otomi.getTeamSelfServiceFlags(teamId), propName, []) - return !flags.includes(name) + // Initialize the team authorization object. + const authz: TeamAuthz = { deniedAttributes: {} } + + // Retrieve the selfService flags for the team. + // Expected shape: { teamMembers: { createServices: boolean, editSecurityPolicies: boolean, ... } } + const selfServiceFlags = otomi.getTeamSelfServiceFlags(teamId)?.teamMembers + + // Initialize deniedAttributes for teamMembers as an empty array. + authz.deniedAttributes.teamMembers = [] + + if (selfServiceFlags) { + // For each permission, if its flag is false then add it to the denied list. + Object.entries(selfServiceFlags).forEach(([permissionName, allowed]) => { + if (!allowed) { + authz.deniedAttributes.teamMembers.push(permissionName) + } }) - if (propName === 'team') authz.deniedAttributes.team.push('selfService') - }) + } else { + // Fallback: if no selfService data is found, deny all permissions. + authz.deniedAttributes.teamMembers = [ + 'createServices', + 'editSecurityPolicies', + 'useCloudShell', + 'downloadKubeconfig', + 'downloadDockerLogin', + ] + } + permissionMap[teamId] = authz }) + return permissionMap } diff --git a/src/middleware/authz.ts b/src/middleware/authz.ts index de03c55c2..bd97cca99 100644 --- a/src/middleware/authz.ts +++ b/src/middleware/authz.ts @@ -4,7 +4,7 @@ import { RequestHandler } from 'express' import { find } from 'lodash' import get from 'lodash/get' import Authz, { getTeamSelfServiceAuthz } from 'src/authz' -import { OpenApiRequestExt, PermissionSchema, TeamSelfService } from 'src/otomi-models' +import { OpenApiRequestExt } from 'src/otomi-models' import OtomiStack from 'src/otomi-stack' import { cleanEnv } from 'src/validators' import { RepoService } from '../services/RepoService' @@ -53,13 +53,11 @@ export function authorize(req: OpenApiRequestExt, res, next, authz: Authz, repoS authz.init(user) let valid - if (action === 'read' && schemaName === 'Kubecfg') - valid = authz.hasSelfService(teamId, 'access', 'downloadKubeConfig') + if (action === 'read' && schemaName === 'Kubecfg') valid = authz.hasSelfService(teamId, 'downloadKubeconfig') else if (action === 'read' && schemaName === 'DockerConfig') - valid = authz.hasSelfService(teamId, 'access', 'downloadDockerConfig') - else if (action === 'create' && schemaName === 'Cloudtty') valid = authz.hasSelfService(teamId, 'access', 'shell') - else if (action === 'update' && schemaName === 'Policy') - valid = authz.hasSelfService(teamId, 'policies', 'edit policies') + valid = authz.hasSelfService(teamId, 'downloadDockerLogin') + else if (action === 'create' && schemaName === 'Cloudtty') valid = authz.hasSelfService(teamId, 'useCloudShell') + else if (action === 'update' && schemaName === 'Policy') valid = authz.hasSelfService(teamId, 'editSecurityPolicies') else valid = authz.validateWithCasl(action, schemaName, teamId) const env = cleanEnv({}) // TODO: Debug purpose only for removal of license @@ -133,18 +131,17 @@ export function authorize(req: OpenApiRequestExt, res, next, authz: Authz, repoS return next() } - export function authzMiddleware(authz: Authz): RequestHandler { // eslint-disable-next-line @typescript-eslint/no-misused-promises return async function nextHandler(req: OpenApiRequestExt, res, next): Promise { - if (req.user) req.isSecurityHandler = true - else return next() + if (req.user) { + req.isSecurityHandler = true + } else { + return next() + } const otomi: OtomiStack = await getSessionStack(req.user.email) - req.user.authz = getTeamSelfServiceAuthz( - req.user.teams, - req.apiDoc.components.schemas.TeamSelfService as TeamSelfService as PermissionSchema, - otomi, - ) + // Now we call the new helper which derives authz based on the new selfService.teamMembers flags. + req.user.authz = getTeamSelfServiceAuthz(req.user.teams, otomi) return authorize(req, res, next, authz, otomi.repoService) } } diff --git a/src/openapi/definitions.yaml b/src/openapi/definitions.yaml index 7155b68cd..153fef188 100644 --- a/src/openapi/definitions.yaml +++ b/src/openapi/definitions.yaml @@ -4,9 +4,9 @@ adminPassword: description: Uses `otomi.adminPassword` when left empty. x-secret: true x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] alerts: additionalProperties: false type: object @@ -29,8 +29,7 @@ alerts: enum: - slack - msteams - - opsgenie - - email + # - email - none type: string uniqueItems: true @@ -50,12 +49,12 @@ alerts: title: Critical type: string url: - $ref: '#/url' + type: string description: A Slack webhook URL. x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true # TODO: Enable again when form rework is done # required: @@ -69,17 +68,17 @@ alerts: title: High prio web hook type: string x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true lowPrio: title: Low prio web hook type: string x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true # TODO: Enable again when form rework is done # required: @@ -87,76 +86,25 @@ alerts: # - lowPrio title: Microsoft Teams type: object - opsgenie: - title: OpsGenie - description: Configure OpsGenie endpoints for alerts. Criticals will get level P1, and non-crits P2. - additionalProperties: false - properties: - apiKey: - description: The api key to use for authn. - title: Api key - type: string - x-secret: true - url: - $ref: '#/url' - description: An opsgenie URL without the '?apiKey' part. - responders: - description: Configure one or multiple alert responders. - type: array - items: - type: object - allOf: - - properties: - type: - type: string - enum: - - team - - user - - escalation - - schedule - default: team - required: [ type ] - - title: Responder - description: An OpsGenie responder identity. - oneOf: - - title: ID - required: [ id ] - properties: - id: - $ref: '#/wordCharacterPattern' - - title: Name - required: [ name ] - properties: - name: - $ref: '#/idName' - - title: Username - required: [ username ] - properties: - username: - $ref: '#/wordCharacterPattern' - # TODO: Enable again when form rework is done - # required: - # - apiKey - # - url - type: object - email: - title: Email - description: Configure email endpoints for alerts. - additionalProperties: false - properties: - critical: - title: Critical Events - $ref: '#/email' - description: One or more email addresses (comma separated) for critical events. - nonCritical: - title: Non-critical Events - $ref: '#/email' - description: One or more email addresses (comma separated) for non-critical events. - # TODO: Enable again when form rework is done - # required: - # - critical - # - nonCritical - type: object + # NOTE: keep this in case email alertReceiver gets re-enabled again + # email: + # title: Email + # description: Configure email endpoints for alerts. + # additionalProperties: false + # properties: + # critical: + # title: Critical Events + # $ref: '#/email' + # description: One or more email addresses (comma separated) for critical events. + # nonCritical: + # title: Non-critical Events + # $ref: '#/email' + # description: One or more email addresses (comma separated) for non-critical events. + # # TODO: Enable again when form rework is done + # # required: + # # - critical + # # - nonCritical + # type: object annotation: type: string pattern: ^((.){1,253}\/)?(.){1,63}$ @@ -248,9 +196,9 @@ azureClientSecret: type: string x-secret: true x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] azureDns: x-hideTitle: true properties: @@ -272,9 +220,9 @@ azureDns: type: string description: Azure Application Client Secret x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true required: - tenantId @@ -385,35 +333,35 @@ dns: description: Akamai Edgegrid API server type: string x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] accessToken: title: Akamai access token description: Akamai Edgegrid API access token type: string x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true clientToken: title: Akamai client token description: Akamai Edgegrid API client token type: string x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true clientSecret: title: Akamai client secret description: Akamai Edgegrid API client secret type: string x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true required: - host @@ -433,15 +381,15 @@ dns: accessKey: $ref: '#/awsAccessKey' x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] type: object region: $ref: '#/awsRegion' role: $ref: '#/awsRole' - required: [ region ] + required: [region] type: object required: - aws @@ -468,17 +416,17 @@ dns: apiToken: type: string x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true apiSecret: type: string description: Required when Email is set. x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true email: $ref: '#/email' @@ -498,9 +446,9 @@ dns: apiToken: type: string x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true type: object required: @@ -514,9 +462,9 @@ dns: apiToken: type: string x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true type: object required: @@ -531,13 +479,13 @@ dns: $ref: '#/googleAccountJson' description: A service account key in json format for managing a DNS zone. x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true project: $ref: '#/googleProject' - required: [ project ] + required: [project] type: object required: - google @@ -556,18 +504,18 @@ dns: type: object description: 'The provider config' x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true cert-manager: title: YAML for cert-manager. type: object description: 'The dns01 config as provided here: https://cert-manager.io/docs/configuration/acme/dns01/' x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true required: - name @@ -590,12 +538,12 @@ droneGit: clientSecretValue: type: string x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] x-secret: true type: object - required: [ clientID, clientSecretValue ] + required: [clientID, clientSecretValue] duration: description: 'Prometheus duration (See: https://prometheus.io/docs/prometheus/latest/configuration/configuration/#configuration-file)' pattern: '((([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?|0)' @@ -775,9 +723,9 @@ kms: privateKey: $ref: '#/agePrivateKey' x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] required: - publicKey - privateKey @@ -802,15 +750,15 @@ kms: accessKey: $ref: '#/awsAccessKey' x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] secretKey: $ref: '#/awsSecretKey' x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] region: $ref: '#/awsRegion' required: @@ -838,15 +786,15 @@ kms: clientId: $ref: '#/azureClientId' x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] clientSecret: $ref: '#/azureClientSecret' x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] tenantId: $ref: '#/azureTenantId' required: @@ -874,9 +822,9 @@ kms: accountJson: $ref: '#/googleAccountJson' x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] project: $ref: '#/googleProject' required: @@ -991,9 +939,9 @@ rawValues: description: 'May define value overrides for a chart. WARNING: these values currently have no schema and will not be validated.' type: object x-acl: - platformAdmin: [ read-any, update-any ] - teamAdmin: [ ] - teamMember: [ ] + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] replicas: type: integer title: Number of replicas @@ -1045,6 +993,7 @@ resourceQuota: value: '50' - name: services.loadbalancers value: '0' + runAsNonRoot: description: Will prevent the container from starting with UID 0. title: Run as non-root @@ -1136,7 +1085,7 @@ secretMounts: path: $ref: '#/path' type: object - required: [ name, path ] + required: [name, path] s3Url: type: string description: An url to an AWS S3 bucket. diff --git a/src/openapi/team.yaml b/src/openapi/team.yaml index 030958c27..f9f2b6861 100644 --- a/src/openapi/team.yaml +++ b/src/openapi/team.yaml @@ -1,50 +1,43 @@ TeamSelfService: - title: Team permissions + title: Team Permissions description: Grant team permissions to modify certain configuration parameters. type: object properties: - service: - title: Service - type: array - items: - type: string - enum: [ingress] - x-allow-values: - ingress: - type: 'cluster' - default: [ingress] - uniqueItems: true - policies: - title: Security policies - type: array - items: - type: string - enum: ['edit policies'] - default: ['edit policies'] - uniqueItems: true - team: - title: Team settings - type: array - items: - type: string - # downloadKubeConfig - is an action that a user can perform, not an attribute of the input form - enum: [oidc, managedMonitoring, alerts, resourceQuota, networkPolicy] - uniqueItems: true - apps: - title: Apps - type: array - items: - type: string - enum: [argocd, gitea] - default: [] - uniqueItems: true - access: - title: Access - type: array - items: - type: string - enum: [shell, downloadKubeConfig, downloadDockerConfig, downloadCertificateAuthority] - uniqueItems: true + teamMembers: + title: Team Members Permissions + type: object + properties: + createServices: + title: Create Services + type: boolean + default: false + description: Permission to create services. + editSecurityPolicies: + title: Edit Security Policies + type: boolean + default: false + description: Permission to edit security policies. + useCloudShell: + title: Use Cloud Shell + type: boolean + default: false + description: Permission to use the cloud shell. + downloadKubeconfig: + title: Download Kubeconfig + type: boolean + default: false + description: Permission to download the kubeconfig. + downloadDockerLogin: + title: Download Docker Login + type: boolean + default: false + description: Permission to download the docker login configuration. + required: + - createServices + - editSecurityPolicies + - useCloudShell + - downloadKubeconfig + - downloadDockerLogin x-acl: platformAdmin: [read-any, update-any] teamAdmin: [read]