Skip to content

Commit 84a58c7

Browse files
authored
feat: add admin apis (CM-1235) (#4211)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent b765ade commit 84a58c7

13 files changed

Lines changed: 1182 additions & 11 deletions

File tree

backend/src/api/public/v1/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { membersRouter } from './members'
1717
import { organizationsRouter } from './organizations'
1818
import { packagesRouter } from './packages'
1919
import { batchGetStewardship } from './packages/batchGetStewardship'
20+
import { stewardshipsRouter } from './stewardships'
2021

2122
const packagesRateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })
2223

@@ -36,6 +37,7 @@ export function v1Router(): Router {
3637
safeWrap(batchGetStewardship),
3738
)
3839
router.use('/packages', oauth2Middleware(AUTH0_CONFIG), packagesRouter())
40+
router.use('/stewardships', oauth2Middleware(AUTH0_CONFIG), stewardshipsRouter())
3941

4042
router.use(() => {
4143
throw new NotFoundError()

backend/src/api/public/v1/packages/getPackage.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import type { Request, Response } from 'express'
22
import { z } from 'zod'
33

44
import { NotFoundError } from '@crowd/common'
5-
import { getAdvisoriesByPackageId, getPackageDetailByPurl } from '@crowd/data-access-layer'
5+
import {
6+
getAdvisoriesByPackageId,
7+
getPackageDetailByPurl,
8+
getStewardshipSummary,
9+
} from '@crowd/data-access-layer'
610

711
import { getPackagesQx } from '@/db/packagesDb'
812
import { ok } from '@/utils/api'
@@ -30,7 +34,10 @@ export async function getPackage(req: Request, res: Response): Promise<void> {
3034
throw new NotFoundError()
3135
}
3236

33-
const advisories = await getAdvisoriesByPackageId(qx, pkg.id)
37+
const [advisories, stewardshipSummary] = await Promise.all([
38+
getAdvisoriesByPackageId(qx, pkg.id),
39+
pkg.stewardshipId ? getStewardshipSummary(qx, Number(pkg.stewardshipId)) : null,
40+
])
3441

3542
ok(res, {
3643
purl: pkg.purl,
@@ -83,9 +90,12 @@ export async function getPackage(req: Request, res: Response): Promise<void> {
8390
},
8491
},
8592
stewardship: {
93+
id: pkg.stewardshipId ?? null,
8694
status: (pkg.stewardshipStatus ?? 'unassigned') as StewardshipStatus,
87-
stewards: null,
88-
lastActivityAt: null,
95+
stewards: stewardshipSummary?.stewards ?? null,
96+
lastActivityAt: stewardshipSummary?.lastActivityAt ?? null,
97+
resolutionPath: pkg.stewardshipResolutionPath ?? null,
98+
statusNote: pkg.stewardshipStatusNote ?? null,
8999
},
90100
history: {},
91101
})

backend/src/api/public/v1/packages/listPackages.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export async function listPackages(req: Request, res: Response): Promise<void> {
8787
lifecycle: null,
8888
maintainerBusFactor: r.maintainerCount,
8989
openVulns: r.openVulns,
90+
stewardshipId: r.stewardshipId ?? null,
9091
stewardship: (r.stewardshipStatus ?? 'unassigned') as StewardshipStatus,
9192
stewards: null,
9293
}))

backend/src/api/public/v1/packages/openapi.yaml

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,17 @@ components:
8585

8686
Steward:
8787
type: object
88-
required: [userId, name, role, assignedAt]
88+
required: [userId, role, assignedAt]
8989
properties:
9090
userId:
9191
type: string
92-
description: LFID.
93-
example: jdoe
92+
description: Auth0 sub of the assigned steward.
93+
example: abc123
9494
name:
95-
type: string
95+
type:
96+
- string
97+
- 'null'
98+
description: Display name of the steward. Null if not available.
9699
example: Jonathan R.
97100
role:
98101
type: string
@@ -194,6 +197,12 @@ components:
194197
oneOf:
195198
- $ref: '#/components/schemas/OpenVulns'
196199
- type: 'null'
200+
stewardshipId:
201+
type:
202+
- string
203+
- 'null'
204+
description: Stewardship ID. Required to call mutation endpoints (assign/escalate/status). Null if no stewardship row exists.
205+
example: '42'
197206
stewardship:
198207
$ref: '#/components/schemas/StewardshipStatus'
199208
stewards:
@@ -418,12 +427,18 @@ components:
418427
- 'null'
419428
stewardship:
420429
type: object
421-
description: Stewardship state. In v1 always unassigned with no stewards or activity.
430+
description: Stewardship state.
422431
properties:
432+
id:
433+
type:
434+
- string
435+
- 'null'
436+
description: Stewardship ID. Required to call mutation endpoints (assign/escalate/status).
437+
example: '42'
423438
status:
424439
$ref: '#/components/schemas/StewardshipStatus'
425440
stewards:
426-
description: Assigned stewards or null. Null in v1.
441+
description: Assigned stewards or null.
427442
oneOf:
428443
- type: array
429444
items:
@@ -434,7 +449,16 @@ components:
434449
- string
435450
- 'null'
436451
format: date-time
437-
description: Null in v1.
452+
resolutionPath:
453+
type:
454+
- string
455+
- 'null'
456+
description: Set on `escalated` status. Null for all other statuses.
457+
statusNote:
458+
type:
459+
- string
460+
- 'null'
461+
description: Free-text note for the current status.
438462
history:
439463
type: object
440464
description: Package history data. Empty in v1.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import { NotFoundError } from '@crowd/common'
5+
import { assignSteward } from '@crowd/data-access-layer'
6+
7+
import { getPackagesQx } from '@/db/packagesDb'
8+
import { ok } from '@/utils/api'
9+
import { validateOrThrow } from '@/utils/validation'
10+
11+
const paramsSchema = z.object({
12+
id: z.coerce.number().int().positive(),
13+
})
14+
15+
const bodySchema = z.object({
16+
userId: z.string().trim().min(1),
17+
role: z.enum(['lead', 'co_steward']),
18+
moveToAssessing: z.boolean().optional().default(false),
19+
})
20+
21+
export async function assignStewardHandler(req: Request, res: Response): Promise<void> {
22+
const { id } = validateOrThrow(paramsSchema, req.params)
23+
const { userId, role, moveToAssessing } = validateOrThrow(bodySchema, req.body)
24+
25+
const qx = await getPackagesQx()
26+
const result = await assignSteward(qx, id, {
27+
userId,
28+
role,
29+
assignedBy: req.actor.id,
30+
moveToAssessing,
31+
})
32+
33+
if (!result) {
34+
throw new NotFoundError(`Stewardship not found: ${id}`)
35+
}
36+
37+
ok(res, { stewardship: result.stewardship, stewards: result.stewards })
38+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import { NotFoundError } from '@crowd/common'
5+
import { ESCALATION_RESOLUTION_PATHS, escalateStewardship } from '@crowd/data-access-layer'
6+
7+
import { getPackagesQx } from '@/db/packagesDb'
8+
import { ok } from '@/utils/api'
9+
import { validateOrThrow } from '@/utils/validation'
10+
11+
const paramsSchema = z.object({
12+
id: z.coerce.number().int().positive(),
13+
})
14+
15+
const bodySchema = z.object({
16+
resolutionPath: z.enum(ESCALATION_RESOLUTION_PATHS),
17+
notes: z.string().trim().min(1).optional(),
18+
})
19+
20+
export async function escalateHandler(req: Request, res: Response): Promise<void> {
21+
const { id } = validateOrThrow(paramsSchema, req.params)
22+
const { resolutionPath, notes } = validateOrThrow(bodySchema, req.body)
23+
24+
const qx = await getPackagesQx()
25+
const stewardship = await escalateStewardship(qx, id, {
26+
resolutionPath,
27+
notes,
28+
actorUserId: req.actor.id,
29+
})
30+
31+
if (!stewardship) {
32+
throw new NotFoundError(`Stewardship not found: ${id}`)
33+
}
34+
35+
ok(res, { stewardship })
36+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Router } from 'express'
2+
3+
import { createRateLimiter } from '@/api/apiRateLimiter'
4+
// TODO: restore once write:stewardships is added to Auth0 staging tenant
5+
// import { requireScopes } from '@/api/public/middlewares/requireScopes'
6+
import { safeWrap } from '@/middlewares/errorMiddleware'
7+
8+
// import { SCOPES } from '@/security/scopes'
9+
import { assignStewardHandler } from './assignSteward'
10+
import { escalateHandler } from './escalate'
11+
import { openStewardship } from './openStewardship'
12+
import { updateStatusHandler } from './updateStatus'
13+
14+
const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })
15+
16+
export function stewardshipsRouter(): Router {
17+
const router = Router()
18+
19+
router.use(rateLimiter)
20+
21+
router.post(
22+
'/',
23+
// TODO: restore once write:stewardships is added to Auth0 staging tenant
24+
// requireScopes([SCOPES.WRITE_STEWARDSHIPS]),
25+
safeWrap(openStewardship),
26+
)
27+
28+
router.put(
29+
'/:id/steward',
30+
// TODO: restore once write:stewardships is added to Auth0 staging tenant
31+
// requireScopes([SCOPES.WRITE_STEWARDSHIPS]),
32+
safeWrap(assignStewardHandler),
33+
)
34+
35+
router.put(
36+
'/:id/escalate',
37+
// TODO: restore once write:stewardships is added to Auth0 staging tenant
38+
// requireScopes([SCOPES.WRITE_STEWARDSHIPS]),
39+
safeWrap(escalateHandler),
40+
)
41+
42+
router.put(
43+
'/:id/status',
44+
// TODO: restore once write:stewardships is added to Auth0 staging tenant
45+
// requireScopes([SCOPES.WRITE_STEWARDSHIPS]),
46+
safeWrap(updateStatusHandler),
47+
)
48+
49+
return router
50+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import { NotFoundError } from '@crowd/common'
5+
import { openStewardshipByPurl } from '@crowd/data-access-layer'
6+
7+
import { getPackagesQx } from '@/db/packagesDb'
8+
import { ok } from '@/utils/api'
9+
import { validateOrThrow } from '@/utils/validation'
10+
11+
import { normalizePurl } from '../packages/purl'
12+
13+
const bodySchema = z.object({
14+
purl: z
15+
.string()
16+
.trim()
17+
.min(1)
18+
.refine((v) => v.startsWith('pkg:'), { message: 'purl must start with pkg:' })
19+
.transform(normalizePurl),
20+
})
21+
22+
export async function openStewardship(req: Request, res: Response): Promise<void> {
23+
const { purl } = validateOrThrow(bodySchema, req.body)
24+
25+
const qx = await getPackagesQx()
26+
const stewardship = await openStewardshipByPurl(qx, purl, req.actor.id)
27+
28+
if (!stewardship) {
29+
throw new NotFoundError(`Package not found: ${purl}`)
30+
}
31+
32+
ok(res, { stewardship })
33+
}

0 commit comments

Comments
 (0)