Skip to content

Commit 94a8eb5

Browse files
authored
Merge branch 'main' into ci/CM-968-api-e2e-tests
2 parents 51b3bbd + 8a7f246 commit 94a8eb5

12 files changed

Lines changed: 498 additions & 27 deletions

File tree

backend/src/api/public/v1/akrites-external/index.ts

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,39 @@ import { getAkritesExternalContactDetailBatch } from '../packages/getAkritesExte
1212
import { getAkritesExternalPackageDetail } from '../packages/getAkritesExternalPackageDetail'
1313
import { getAkritesExternalPackageDetailBatch } from '../packages/getAkritesExternalPackageDetailBatch'
1414
import { getBlastRadiusJob } from '../packages/getBlastRadiusJob'
15+
import { ingestAkritesExternalContactDetail } from '../packages/ingestAkritesExternalContactDetail'
1516
import { submitBlastRadiusJob } from '../packages/submitBlastRadiusJob'
1617

1718
const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })
1819

19-
// Blast-radius jobs kick off a Temporal workflow per request, so they get their own,
20-
// much stricter limiter — configurable via env so it can be tuned without a redeploy.
21-
// Defaults to 5 requests/hour.
22-
const blastRadiusRateLimitMax = Number(process.env.AKRITES_BLAST_RADIUS_RATE_LIMIT_MAX)
23-
const blastRadiusRateLimitWindowMs = Number(process.env.AKRITES_BLAST_RADIUS_RATE_LIMIT_WINDOW_MS)
20+
// Shared by every endpoint below that kicks off a Temporal workflow per request — those
21+
// get their own, much stricter limiter than plain reads, configurable via env so it can
22+
// be tuned without a redeploy.
23+
function envTunableRateLimiter(envPrefix: string, defaultMax: number, defaultWindowMs: number) {
24+
const max = Number(process.env[`${envPrefix}_MAX`])
25+
const windowMs = Number(process.env[`${envPrefix}_WINDOW_MS`])
26+
return createRateLimiter({
27+
max: Number.isSafeInteger(max) && max > 0 ? max : defaultMax,
28+
windowMs: Number.isSafeInteger(windowMs) && windowMs > 0 ? windowMs : defaultWindowMs,
29+
})
30+
}
31+
32+
// Blast-radius jobs default to 5 requests/hour.
33+
const blastRadiusRateLimiter = envTunableRateLimiter(
34+
'AKRITES_BLAST_RADIUS_RATE_LIMIT',
35+
5,
36+
60 * 60 * 1000,
37+
)
2438

25-
const blastRadiusRateLimiter = createRateLimiter({
26-
max:
27-
Number.isSafeInteger(blastRadiusRateLimitMax) && blastRadiusRateLimitMax > 0
28-
? blastRadiusRateLimitMax
29-
: 5,
30-
windowMs:
31-
Number.isSafeInteger(blastRadiusRateLimitWindowMs) && blastRadiusRateLimitWindowMs > 0
32-
? blastRadiusRateLimitWindowMs
33-
: 60 * 60 * 1000,
34-
})
39+
// /contacts/ingest starts a Temporal workflow and blocks for it (worst case ~95s per
40+
// attempt cycle, plus unbounded time waiting for a free worker slot — see
41+
// security-contacts/workflows.ts's singleActs config), vs. the read-only /contacts/detail
42+
// endpoints, so it gets its own limiter. Defaults to 20 requests/hour.
43+
const contactIngestRateLimiter = envTunableRateLimiter(
44+
'AKRITES_CONTACT_INGEST_RATE_LIMIT',
45+
20,
46+
60 * 60 * 1000,
47+
)
3548

3649
export function akritesExternalRouter(): Router {
3750
const router = Router()
@@ -62,11 +75,33 @@ export function akritesExternalRouter(): Router {
6275
// them via the packages scope. That scope isn't issued by Auth0 yet, so reuse the
6376
// closest issued one — READ_MAINTAINER_ROLES (maintainer data) — NOT READ_PACKAGES.
6477
// TODO: swap for cdp:maintainers:read once issued.
78+
// requireScopes is applied per-route (not router-level) so each route can put its own
79+
// rate limiter *before* the scope check — failed-auth requests still count against that
80+
// route's quota — without forcing every route in this subrouter onto the same limiter
81+
// instance. /ingest gets its own dedicated contactIngestRateLimiter instead of sharing
82+
// the read endpoints' quota, matching the blast-radius jobs endpoint below.
83+
const contactsScopes = [SCOPES.READ_MAINTAINER_ROLES]
6584
const contactsSubRouter = Router()
66-
contactsSubRouter.use(rateLimiter)
67-
contactsSubRouter.use(requireScopes([SCOPES.READ_MAINTAINER_ROLES]))
68-
contactsSubRouter.get('/detail', safeWrap(getAkritesExternalContactDetail))
69-
contactsSubRouter.post(/^\/detail:batch\/?$/, safeWrap(getAkritesExternalContactDetailBatch))
85+
contactsSubRouter.get(
86+
'/detail',
87+
rateLimiter,
88+
requireScopes(contactsScopes),
89+
safeWrap(getAkritesExternalContactDetail),
90+
)
91+
contactsSubRouter.post(
92+
/^\/detail:batch\/?$/,
93+
rateLimiter,
94+
requireScopes(contactsScopes),
95+
safeWrap(getAkritesExternalContactDetailBatch),
96+
)
97+
// Sync, single-purl on-demand ingest — starts a Temporal workflow and blocks a while,
98+
// so it gets the dedicated contactIngestRateLimiter, not the shared rateLimiter above.
99+
contactsSubRouter.post(
100+
'/ingest',
101+
contactIngestRateLimiter,
102+
requireScopes(contactsScopes),
103+
safeWrap(ingestAkritesExternalContactDetail),
104+
)
70105
router.use('/contacts', contactsSubRouter)
71106

72107
// TODO: the contract gates blast-radius behind a dedicated read:advisories scope

backend/src/api/public/v1/akrites-external/openapi.yaml

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,9 @@ components:
686686
- vulnerabilityReportingUrl
687687
- bugBountyUrl
688688
- pvrEnabled
689+
- declaredRepositoryUrl
690+
- resolvedRepositoryUrl
691+
- repoMappingConfidence
689692
- targetOrganizationName
690693
- bugBountyProgramFlag
691694
- reportingMethods
@@ -716,6 +719,28 @@ components:
716719
pvrEnabled:
717720
type: boolean
718721
nullable: true
722+
declaredRepositoryUrl:
723+
type: string
724+
nullable: true
725+
description: Raw repository URL as declared in the package's own metadata.
726+
resolvedRepositoryUrl:
727+
type: string
728+
nullable: true
729+
description: >
730+
Best available repository URL, resolved via the confidence-ranked
731+
package_repos → repos join — the repo CDP has actually resolved and
732+
enriched (scorecard, branch protection, etc.). Null when the
733+
package has no package_repos link yet.
734+
repoMappingConfidence:
735+
type: number
736+
format: float
737+
minimum: 0
738+
maximum: 1
739+
nullable: true
740+
description: >
741+
Confidence score of the package_repos mapping used to resolve
742+
resolvedRepositoryUrl. Null when the package has no package_repos
743+
link yet.
719744
targetOrganizationName:
720745
type: string
721746
nullable: true
@@ -1221,3 +1246,94 @@ paths:
12211246
application/json:
12221247
schema:
12231248
$ref: '#/components/schemas/Error'
1249+
1250+
/akrites-external/contacts/ingest:
1251+
post:
1252+
operationId: ingestContactDetail
1253+
summary: On-demand security-contacts ingest for a single PURL
1254+
description: >
1255+
Synchronous, single-purl only — no batch variant, since fanning this out over
1256+
many purls would multiply concurrent Temporal workflow starts.
1257+
1258+
If the linked repo's security contacts have already been ingested at least
1259+
once, returns the existing ContactDetail immediately without triggering the
1260+
workflow. If the purl is unknown, or the package has no linked repo at all,
1261+
returns 404 immediately without triggering the workflow either. Otherwise
1262+
blocks while a single-repo Temporal workflow (ingestSecurityContactsForPurlWorkflow)
1263+
ingests the linked repo's security contacts — worst case ~95s per attempt
1264+
cycle, plus unbounded time waiting for a free worker slot — then returns the
1265+
same ContactDetail payload as GET /contacts/detail. Concurrent requests for
1266+
the same purl attach to the same running workflow instead of starting
1267+
duplicates.
1268+
1269+
1270+
Note: the underlying workflow itself has no "already ingested" gate — it
1271+
reprocesses and can replace a repo's contacts on every invocation. This
1272+
endpoint is what prevents repeat calls from re-triggering it for a purl
1273+
that's already been ingested.
1274+
1275+
1276+
Rate limited independently of the other /contacts endpoints — default
1277+
20 requests/hour, tunable via AKRITES_CONTACT_INGEST_RATE_LIMIT_MAX /
1278+
AKRITES_CONTACT_INGEST_RATE_LIMIT_WINDOW_MS.
1279+
1280+
1281+
Not yet implemented: this is a synchronous, blocking call. Future work should
1282+
make this async — an accept-and-poll job, similar to POST /blast-radius/jobs —
1283+
so callers aren't held open for the duration of the ingest.
1284+
tags: [Contacts]
1285+
security:
1286+
- M2MBearer:
1287+
- read:maintainer-roles
1288+
requestBody:
1289+
required: true
1290+
content:
1291+
application/json:
1292+
schema:
1293+
type: object
1294+
required: [purl]
1295+
properties:
1296+
purl:
1297+
type: string
1298+
example: pkg:npm/%40angular/core
1299+
responses:
1300+
'200':
1301+
description: >-
1302+
Security contact detail — either just ingested by this call, or
1303+
returned from a prior ingest without re-triggering the workflow.
1304+
content:
1305+
application/json:
1306+
schema:
1307+
$ref: '#/components/schemas/ContactDetail'
1308+
'400':
1309+
description: Malformed purl.
1310+
content:
1311+
application/json:
1312+
schema:
1313+
$ref: '#/components/schemas/Error'
1314+
'401':
1315+
description: Missing or invalid bearer token.
1316+
content:
1317+
application/json:
1318+
schema:
1319+
$ref: '#/components/schemas/Error'
1320+
'403':
1321+
description: Token missing read:maintainer-roles scope.
1322+
content:
1323+
application/json:
1324+
schema:
1325+
$ref: '#/components/schemas/Error'
1326+
'404':
1327+
description: >-
1328+
Purl is unknown, or the package has no linked repo to ingest
1329+
contacts from — returned without triggering the workflow.
1330+
content:
1331+
application/json:
1332+
schema:
1333+
$ref: '#/components/schemas/Error'
1334+
'429':
1335+
description: Too many requests — rate-limited independently of the other endpoints.
1336+
content:
1337+
application/json:
1338+
schema:
1339+
$ref: '#/components/schemas/Error'

backend/src/api/public/v1/packages/akritesExternalContactDetail.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ function baseRow(
1515
vulnerabilityReportingUrl: null,
1616
bugBountyUrl: null,
1717
pvrEnabled: true,
18+
declaredRepositoryUrl: 'https://github.com/lodash/lodash.git',
19+
resolvedRepositoryUrl: 'https://github.com/lodash/lodash',
20+
repoMappingConfidence: 0.9,
21+
contactsLastRefreshed: '2024-01-01T00:00:00.000Z',
1822
securityContacts: [
1923
{
2024
channel: 'email',
@@ -107,4 +111,31 @@ describe('toAkritesExternalContactDetail', () => {
107111
expect(result.vulnerabilityReportingUrl).toBe('https://example.org/report')
108112
expect(result.pvrEnabled).toBe(false)
109113
})
114+
115+
it('passes through the repo provenance fields when present', () => {
116+
const result = toAkritesExternalContactDetail(baseRow())
117+
expect(result.declaredRepositoryUrl).toBe('https://github.com/lodash/lodash.git')
118+
expect(result.resolvedRepositoryUrl).toBe('https://github.com/lodash/lodash')
119+
expect(result.repoMappingConfidence).toBe(0.9)
120+
})
121+
122+
it('casts repoMappingConfidence from a numeric string (pg-promise numeric type)', () => {
123+
const result = toAkritesExternalContactDetail(
124+
baseRow({ repoMappingConfidence: '0.9' as unknown as number }),
125+
)
126+
expect(result.repoMappingConfidence).toBe(0.9)
127+
})
128+
129+
it('returns resolvedRepositoryUrl and repoMappingConfidence as null when there is no repo link', () => {
130+
const result = toAkritesExternalContactDetail(
131+
baseRow({ resolvedRepositoryUrl: null, repoMappingConfidence: null }),
132+
)
133+
expect(result.resolvedRepositoryUrl).toBeNull()
134+
expect(result.repoMappingConfidence).toBeNull()
135+
})
136+
137+
it('returns declaredRepositoryUrl as null when the package has no declared repository', () => {
138+
const result = toAkritesExternalContactDetail(baseRow({ declaredRepositoryUrl: null }))
139+
expect(result.declaredRepositoryUrl).toBeNull()
140+
})
110141
})

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
securityContactConfidenceBand,
55
} from '@crowd/data-access-layer'
66

7+
import { toNullableNumber } from './mappers'
8+
79
// Both the per-contact band and the aggregate band mirror the internal
810
// SecurityContactConfidence enum verbatim (PRIMARY/SECONDARY/FALLBACK/NONE) —
911
// no external crosswalk (confirmed with product).
@@ -28,6 +30,9 @@ export interface AkritesExternalContactDetail {
2830
vulnerabilityReportingUrl: string | null
2931
bugBountyUrl: string | null
3032
pvrEnabled: boolean | null
33+
declaredRepositoryUrl: string | null
34+
resolvedRepositoryUrl: string | null
35+
repoMappingConfidence: number | null
3136
// Reserved by the contract but not stored today — always null (see the akrites-external
3237
// OpenAPI notes). Typed as null so a future non-null shape is an explicit, reviewed change.
3338
targetOrganizationName: null
@@ -71,6 +76,9 @@ export function toAkritesExternalContactDetail(
7176
vulnerabilityReportingUrl: row.vulnerabilityReportingUrl ?? null,
7277
bugBountyUrl: row.bugBountyUrl ?? null,
7378
pvrEnabled: row.pvrEnabled ?? null,
79+
declaredRepositoryUrl: row.declaredRepositoryUrl ?? null,
80+
resolvedRepositoryUrl: row.resolvedRepositoryUrl ?? null,
81+
repoMappingConfidence: toNullableNumber(row.repoMappingConfidence),
7482
targetOrganizationName: null,
7583
bugBountyProgramFlag: null,
7684
reportingMethods: null,

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
computeHealthBand,
55
} from '@crowd/data-access-layer'
66

7-
import { repoMappingLabel, snakeToCamelKeys } from './mappers'
7+
import { repoMappingLabel, snakeToCamelKeys, toNullableNumber } from './mappers'
88
import { HEALTH_BAND_SET, LIFECYCLE_VALUES, type Lifecycle } from './types'
99

1010
const LIFECYCLE_SET = new Set<string>(LIFECYCLE_VALUES)
@@ -102,8 +102,7 @@ export function toAkritesExternalPackageDetail(
102102
row: AkritesExternalPackageDetailRow,
103103
): AkritesExternalPackageDetail {
104104
const scorecardScore = row.scorecardScore != null ? Number(row.scorecardScore) : null
105-
const mappingConfidence =
106-
row.repoMappingConfidence != null ? Number(row.repoMappingConfidence) : null
105+
const mappingConfidence = toNullableNumber(row.repoMappingConfidence)
107106

108107
return {
109108
purl: row.purl,

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { getPackagesQx } from '@/db/packagesDb'
1313
import { ok } from '@/utils/api'
1414
import { validateOrThrow } from '@/utils/validation'
1515

16-
import { repoMappingLabel, snakeToCamelKeys } from './mappers'
16+
import { repoMappingLabel, snakeToCamelKeys, toNullableNumber } from './mappers'
1717
import { purlQuerySchema } from './purl'
1818
import { HEALTH_BAND_SET, LIFECYCLE_VALUES, type StewardshipStatus } from './types'
1919

@@ -35,8 +35,7 @@ export async function getPackage(req: Request, res: Response): Promise<void> {
3535
])
3636

3737
const scorecardScore = pkg.scorecardScore != null ? Number(pkg.scorecardScore) : null
38-
const mappingConfidence =
39-
pkg.repoMappingConfidence != null ? Number(pkg.repoMappingConfidence) : null
38+
const mappingConfidence = toNullableNumber(pkg.repoMappingConfidence)
4039

4140
const securityContacts =
4241
pkg.contactsLastRefreshed == null

0 commit comments

Comments
 (0)