Skip to content

Commit d00eaab

Browse files
authored
Merge branch 'main' into APL-861
2 parents 8b7326b + 727494c commit d00eaab

File tree

9 files changed

+286
-26
lines changed

9 files changed

+286
-26
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
44

5+
## [4.5.0](https://github.com/redkubes/otomi-api/compare/v4.4.0...v4.5.0) (2025-06-03)
6+
7+
8+
### Bug Fixes
9+
10+
* remove unused obj property from settingsInfo ([#730](https://github.com/redkubes/otomi-api/issues/730)) ([48c6437](https://github.com/redkubes/otomi-api/commit/48c643713da7fde7365ff186006d05f539a82560))
11+
* security policies & self service permissions ([#732](https://github.com/redkubes/otomi-api/issues/732)) ([96ad35e](https://github.com/redkubes/otomi-api/commit/96ad35e91f0b0e94ea26455e1c3a185136ad937b))
12+
* team(s) endpoints ([#733](https://github.com/redkubes/otomi-api/issues/733)) ([85be61c](https://github.com/redkubes/otomi-api/commit/85be61caa8c16563e3e48cd01188beaa3eea1f52))
13+
14+
15+
### Others
16+
17+
* add code owners ([#727](https://github.com/redkubes/otomi-api/issues/727)) ([8c9689c](https://github.com/redkubes/otomi-api/commit/8c9689c4093f814e3cc0eb76a0b4bd653c1cc15e))
18+
* do not run postman for dependabot and update some deps ([#731](https://github.com/redkubes/otomi-api/issues/731)) ([afd648b](https://github.com/redkubes/otomi-api/commit/afd648bf93444001c881ba825203de11d8967a1f))
19+
520
## [4.4.0](https://github.com/redkubes/otomi-api/compare/v4.3.0...v4.4.0) (2025-05-14)
621

722

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@
165165
"tag": true
166166
}
167167
},
168-
"version": "4.4.0",
168+
"version": "4.5.0",
169169
"watch": {
170170
"build:models": {
171171
"patterns": [

src/api.authz.test.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
/* eslint-disable @typescript-eslint/no-empty-function */
2-
1+
import { Express } from 'express'
32
import { mockDeep } from 'jest-mock-extended'
43
import { initApp, loadSpec } from 'src/app'
54
import getToken from 'src/fixtures/jwt'
@@ -9,9 +8,8 @@ import { HttpError } from './error'
98
import { Git } from './git'
109
import { getSessionStack } from './middleware'
1110
import { App, CodeRepo, Repo, SealedSecret } from './otomi-models'
12-
import * as getValuesSchemaModule from './utils'
1311
import { RepoService } from './services/RepoService'
14-
import { Express } from 'express'
12+
import * as getValuesSchemaModule from './utils'
1513

1614
const platformAdminToken = getToken(['platform-admin'])
1715
const teamAdminToken = getToken(['team-admin', 'team-team1'])
@@ -152,11 +150,11 @@ describe('API authz tests', () => {
152150
expect(response.body.values).toEqual(values)
153151
})
154152

155-
test('team member can get all teams', async () => {
153+
test('team member cannot get all teams', async () => {
156154
await agent
157155
.get('/v1/teams')
158156
.set('Authorization', `Bearer ${teamMemberToken}`)
159-
.expect(200)
157+
.expect(403)
160158
.expect('Content-Type', /json/)
161159
})
162160

@@ -172,12 +170,12 @@ describe('API authz tests', () => {
172170
.expect(403)
173171
})
174172

175-
test('team member can get other teams', async () => {
173+
test('team member cannot get other teams', async () => {
176174
jest.spyOn(otomiStack, 'getTeam').mockResolvedValue({} as never)
177175
await agent
178176
.get('/v1/teams/team2')
179177
.set('Authorization', `Bearer ${teamMemberToken}`)
180-
.expect(200)
178+
.expect(403)
181179
.expect('Content-Type', /json/)
182180
})
183181

src/authz.test.ts

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,242 @@ describe('Schema collection wise permissions', () => {
134134
sessionTeam.authz = {}
135135
})
136136
})
137+
138+
describe('Self-service permissions', () => {
139+
const spec: OpenAPIDoc = {
140+
components: {
141+
schemas: {
142+
Kubecfg: { type: 'object', 'x-acl': { teamMember: ['read'] }, properties: {} },
143+
DockerConfig: { type: 'object', 'x-acl': { teamMember: ['read'] }, properties: {} },
144+
Cloudtty: { type: 'object', 'x-acl': { teamMember: ['create'] }, properties: {} },
145+
Policy: { type: 'object', 'x-acl': { teamMember: ['update'] }, properties: {} },
146+
},
147+
},
148+
paths: {},
149+
security: [],
150+
}
151+
152+
const teamId = 'mercury'
153+
let authz: Authz
154+
beforeEach(() => {
155+
authz = new Authz(spec).init({ ...sessionTeam, teams: [teamId] })
156+
})
157+
158+
test('Team member can download kubeconfig (self-service)', () => {
159+
expect(authz.hasSelfService(teamId, 'downloadKubeconfig')).toBeDefined()
160+
})
161+
test('Team member can download docker login (self-service)', () => {
162+
expect(authz.hasSelfService(teamId, 'downloadDockerLogin')).toBeDefined()
163+
})
164+
test('Team member can use cloud shell (self-service)', () => {
165+
expect(authz.hasSelfService(teamId, 'useCloudShell')).toBeDefined()
166+
})
167+
test('Team member can edit security policies (self-service)', () => {
168+
expect(authz.hasSelfService(teamId, 'editSecurityPolicies')).toBeDefined()
169+
})
170+
test('Team member cannot use undefined self-service permission', () => {
171+
expect(authz.hasSelfService(teamId, 'notARealPermission')).toBeDefined()
172+
})
173+
})
174+
175+
describe('Authz middleware cases', () => {
176+
const spec: OpenAPIDoc = {
177+
components: { schemas: { Service: { type: 'object', 'x-acl': { teamMember: ['read'] }, properties: {} } } },
178+
paths: {},
179+
security: [],
180+
}
181+
let authz: Authz
182+
beforeEach(() => {
183+
authz = new Authz(spec).init(sessionTeam)
184+
})
185+
186+
test('Returns false if user is not in team', () => {
187+
expect(authz.validateWithCasl('read', 'Service', 'notMyTeam')).toBe(false)
188+
})
189+
test('Returns true if user is in team', () => {
190+
expect(authz.validateWithCasl('read', 'Service', 'mercury')).toBe(true)
191+
})
192+
test('Returns false if schema is missing', () => {
193+
expect(authz.validateWithCasl('read', '', 'mercury')).toBe(false)
194+
})
195+
test('Returns false if action is not allowed', () => {
196+
expect(authz.validateWithCasl('delete', 'Service', 'mercury')).toBe(false)
197+
})
198+
})
199+
200+
describe('Platform admin, team admin and team member scenarios', () => {
201+
const spec: OpenAPIDoc = {
202+
components: {
203+
schemas: {
204+
Resource: {
205+
type: 'object',
206+
'x-acl': {
207+
platformAdmin: ['create-any', 'read-any', 'update-any', 'delete-any'],
208+
teamAdmin: ['create', 'read', 'update', 'delete'],
209+
teamMember: ['read'],
210+
},
211+
properties: {},
212+
},
213+
},
214+
},
215+
paths: {},
216+
security: [],
217+
}
218+
const platformAdmin: SessionUser = { ...sessionTeam, isPlatformAdmin: true, roles: [SessionRole.PlatformAdmin] }
219+
const teamAdmin: SessionUser = { ...sessionTeam, isTeamAdmin: true, roles: [SessionRole.TeamAdmin] }
220+
const myTeam = 'mercury'
221+
const otherTeam = 'venus'
222+
223+
test('Platform admin can CRUD any resource', () => {
224+
const authz = new Authz(spec).init(platformAdmin)
225+
expect(authz.validateWithCasl('create', 'Resource', 'anyTeam')).toBe(true)
226+
expect(authz.validateWithCasl('read', 'Resource', 'anyTeam')).toBe(true)
227+
expect(authz.validateWithCasl('update', 'Resource', 'anyTeam')).toBe(true)
228+
expect(authz.validateWithCasl('delete', 'Resource', 'anyTeam')).toBe(true)
229+
})
230+
test('Team admin can CRUD resources in their team', () => {
231+
const authz = new Authz(spec).init(teamAdmin)
232+
expect(authz.validateWithCasl('create', 'Resource', myTeam)).toBe(true)
233+
expect(authz.validateWithCasl('read', 'Resource', myTeam)).toBe(true)
234+
expect(authz.validateWithCasl('update', 'Resource', myTeam)).toBe(true)
235+
expect(authz.validateWithCasl('delete', 'Resource', myTeam)).toBe(true)
236+
})
237+
test('Team admin cannot CRUD resources in other teams', () => {
238+
const authz = new Authz(spec).init(teamAdmin)
239+
expect(authz.validateWithCasl('create', 'Resource', otherTeam)).toBe(false)
240+
expect(authz.validateWithCasl('read', 'Resource', otherTeam)).toBe(false)
241+
expect(authz.validateWithCasl('update', 'Resource', otherTeam)).toBe(false)
242+
expect(authz.validateWithCasl('delete', 'Resource', otherTeam)).toBe(false)
243+
})
244+
test('Team member can Read resources', () => {
245+
const authz = new Authz(spec).init(sessionTeam)
246+
expect(authz.validateWithCasl('read', 'Resource', myTeam)).toBe(true)
247+
})
248+
test('Team member cannot CUD resources', () => {
249+
const authz = new Authz(spec).init(sessionTeam)
250+
expect(authz.validateWithCasl('create', 'Resource', myTeam)).toBe(false)
251+
expect(authz.validateWithCasl('update', 'Resource', myTeam)).toBe(false)
252+
expect(authz.validateWithCasl('delete', 'Resource', myTeam)).toBe(false)
253+
})
254+
test('Team member cannot CRUD resources in other teams', () => {
255+
const authz = new Authz(spec).init(sessionTeam)
256+
expect(authz.validateWithCasl('create', 'Resource', otherTeam)).toBe(false)
257+
expect(authz.validateWithCasl('read', 'Resource', otherTeam)).toBe(false)
258+
expect(authz.validateWithCasl('update', 'Resource', otherTeam)).toBe(false)
259+
expect(authz.validateWithCasl('delete', 'Resource', otherTeam)).toBe(false)
260+
})
261+
test('Platform admin can perform self-service actions', () => {
262+
const authz = new Authz(spec).init(platformAdmin)
263+
expect(authz.hasSelfService('anyTeam', 'downloadKubeconfig')).toBeDefined()
264+
expect(authz.hasSelfService('anyTeam', 'downloadDockerLogin')).toBeDefined()
265+
expect(authz.hasSelfService('anyTeam', 'useCloudShell')).toBeDefined()
266+
expect(authz.hasSelfService('anyTeam', 'editSecurityPolicies')).toBeDefined()
267+
})
268+
})
269+
270+
describe('ABAC attribute denial', () => {
271+
const spec: OpenAPIDoc = {
272+
components: {
273+
schemas: {
274+
Team: { type: 'object', 'x-acl': { teamMember: ['update'] }, properties: {} },
275+
},
276+
},
277+
paths: {},
278+
security: [],
279+
}
280+
const teamId = 'teamA'
281+
let authz: Authz
282+
beforeEach(() => {
283+
authz = new Authz(spec).init(sessionTeam)
284+
sessionTeam.authz = { [teamId]: { deniedAttributes: { Team: ['foo', 'bar'] } } }
285+
})
286+
test('Denied attributes are respected', () => {
287+
expect(() => authz.hasSelfService(teamId, 'foo')).not.toThrow()
288+
expect(() => authz.hasSelfService(teamId, 'bar')).not.toThrow()
289+
})
290+
test('Allowed attribute is not denied', () => {
291+
expect(() => authz.hasSelfService(teamId, 'baz')).not.toThrow()
292+
})
293+
afterEach(() => {
294+
sessionTeam.authz = {}
295+
})
296+
})
297+
298+
describe('Fallback to CASL when no self-service permission', () => {
299+
const spec: OpenAPIDoc = {
300+
components: {
301+
schemas: {
302+
App: { type: 'object', 'x-acl': { teamMember: ['read'] }, properties: {} },
303+
},
304+
},
305+
paths: {},
306+
security: [],
307+
}
308+
let authz: Authz
309+
beforeEach(() => {
310+
authz = new Authz(spec).init(sessionTeam)
311+
})
312+
test('Falls back to CASL for non-self-service action', () => {
313+
expect(authz.validateWithCasl('read', 'App', 'mercury')).toBe(true)
314+
})
315+
test('Returns false for denied action', () => {
316+
expect(authz.validateWithCasl('delete', 'App', 'mercury')).toBe(false)
317+
})
318+
})
319+
320+
describe('Team member self-service vs. other team resources', () => {
321+
const spec: OpenAPIDoc = {
322+
components: {
323+
schemas: {
324+
Service: {
325+
type: 'object',
326+
'x-acl': {
327+
teamMember: ['read', 'update', 'delete', 'create'],
328+
},
329+
properties: {
330+
name: { type: 'string' },
331+
teamId: { type: 'string' },
332+
},
333+
},
334+
},
335+
},
336+
paths: {},
337+
security: [],
338+
}
339+
const myTeam = 'mercury'
340+
const otherTeam = 'venus'
341+
let authz: Authz
342+
beforeEach(() => {
343+
authz = new Authz(spec).init({ ...sessionTeam, teams: [myTeam] })
344+
})
345+
346+
test('Team member can CRUD own team resource', () => {
347+
expect(authz.validateWithCasl('create', 'Service', myTeam)).toBe(true)
348+
expect(authz.validateWithCasl('read', 'Service', myTeam)).toBe(true)
349+
expect(authz.validateWithCasl('update', 'Service', myTeam)).toBe(true)
350+
expect(authz.validateWithCasl('delete', 'Service', myTeam)).toBe(true)
351+
})
352+
353+
test('Team member cannot CRUD another team resource', () => {
354+
expect(authz.validateWithCasl('create', 'Service', otherTeam)).toBe(false)
355+
expect(authz.validateWithCasl('read', 'Service', otherTeam)).toBe(false)
356+
expect(authz.validateWithCasl('update', 'Service', otherTeam)).toBe(false)
357+
expect(authz.validateWithCasl('delete', 'Service', otherTeam)).toBe(false)
358+
})
359+
360+
test('Team member with no self-service permission cannot perform custom self-service action', () => {
361+
sessionTeam.authz = { [myTeam]: { deniedAttributes: { Policy: ['editSecurityPolicies'] } } }
362+
expect(() => authz.hasSelfService(myTeam, 'editSecurityPolicies')).not.toThrow()
363+
sessionTeam.authz = {}
364+
})
365+
366+
test('Team member with no self-service permission cannot perform custom self-service action in another team', () => {
367+
sessionTeam.authz = { [otherTeam]: { deniedAttributes: { Policy: ['editSecurityPolicies'] } } }
368+
expect(() => authz.hasSelfService(myTeam, 'editSecurityPolicies')).not.toThrow()
369+
sessionTeam.authz = {}
370+
})
371+
372+
test('Team member with self-service permission can perform allowed self-service action', () => {
373+
expect(() => authz.hasSelfService(myTeam, 'read')).not.toThrow()
374+
})
375+
})

src/middleware/authz.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import Authz, { getTeamSelfServiceAuthz } from 'src/authz'
77
import { HttpError } from 'src/error'
88
import { OpenApiRequestExt } from 'src/otomi-models'
99
import OtomiStack from 'src/otomi-stack'
10-
import { cleanEnv } from 'src/validators'
1110
import { RepoService } from '../services/RepoService'
1211
import { getSessionStack } from './session'
1312

@@ -54,15 +53,20 @@ export function authorize(req: OpenApiRequestExt, res, next, authz: Authz, repoS
5453
authz.init(user)
5554

5655
let valid
57-
if (action === 'read' && schemaName === 'Kubecfg') valid = authz.hasSelfService(teamId, 'downloadKubeconfig')
58-
else if (action === 'read' && schemaName === 'DockerConfig')
59-
valid = authz.hasSelfService(teamId, 'downloadDockerLogin')
60-
else if (action === 'create' && schemaName === 'Cloudtty') valid = authz.hasSelfService(teamId, 'useCloudShell')
61-
else if (action === 'update' && schemaName === 'Policy') valid = authz.hasSelfService(teamId, 'editSecurityPolicies')
62-
else valid = authz.validateWithCasl(action, schemaName, teamId)
63-
const env = cleanEnv({})
64-
// TODO: Debug purpose only for removal of license
65-
if (!env.isDev && !valid) {
56+
const isTeamMember = !user.isPlatformAdmin && user.teams.includes(teamId)
57+
if (isTeamMember) {
58+
const permissionMap: Record<string, string> = {
59+
'read:Kubecfg': 'downloadKubeconfig',
60+
'read:DockerConfig': 'downloadDockerLogin',
61+
'create:Cloudtty': 'useCloudShell',
62+
'update:Policy': 'editSecurityPolicies',
63+
}
64+
const key = `${action}:${schemaName}`
65+
const permission = permissionMap[key]
66+
valid = permission ? authz.hasSelfService(teamId, permission) : authz.validateWithCasl(action, schemaName, teamId)
67+
} else valid = authz.validateWithCasl(action, schemaName, teamId)
68+
69+
if (!valid) {
6670
throw new HttpError(403, `User not allowed to perform "${action}" on "${schemaName}" resource`)
6771
}
6872

src/openapi/api.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1929,7 +1929,7 @@ paths:
19291929
get:
19301930
operationId: getAplCodeRepo
19311931
description: Get a code repo from a given team
1932-
x-aclSchema: Coderepo
1932+
x-aclSchema: CodeRepo
19331933
responses:
19341934
<<: *DefaultGetResponses
19351935
'200':

src/openapi/team.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,10 @@ Team:
106106
- create-any
107107
- update-any
108108
teamAdmin:
109-
- read-any
109+
- read
110110
- update
111111
teamMember:
112-
- read-any
112+
- read
113113
- update
114114
type: object
115115
x-externalDocsPath: docs/for-ops/console/teams

0 commit comments

Comments
 (0)