feat(ascp): Add GraphQL API for ASCP scans
Summary
Implements the GraphQL API for querying and creating ASCP scans, enabling scan-based versioning for ASCP security contexts.
Changes:
- Add
ScanType(AscpScan) andScanTypeEnum(AscpScanType) GraphQL types - Add
ScansResolverfor project-level scan queries with filtering by scan type - Add
ScanCreatemutation with automatic scan_sequence calculation via exclusive lease - Add
CreateScanServicewith atomic scan_sequence generation - Add
ScanPolicywith read/create permissions delegating to project - Add permission definitions (
read_ascp_scan,create_ascp_scan) - Extend
ProjectTypewithascp_scansfield (experiment, milestone 18.10) - Add
has_many :security_ascp_scansassociation to Project model - Add
by_scan_typescope to Scan model - Register
security_ascp_scansin import/export model configuration - Add comprehensive specs (integration, unit, policy, finder, service)
Related Issue
https://gitlab.com/gitlab-org/gitlab/-/issues/588900
Database
Queries introduced by this MR
All queries operate on the ascp_scans table which has the following indexes (from the table migration MR):
-
index_ascp_scans_on_project_id_and_scan_sequence(unique, composite) index_ascp_scans_on_commit_shaindex_ascp_scans_on_base_scan_idindex_ascp_scans_on_project_id_and_scan_type
Note: ascp_scans is a new table with no production data yet. Per the database review guidelines, we seeded data on postgres.ai to produce realistic query plans.
The seed data reflects the expected access pattern: very few full scans (periodic re-baselines) and many incremental scans (one per commit on the default branch).
Seed data queries (click to expand)
-- Insert 5 full scans for gitlab-org/gitlab (project_id = 278964)
INSERT INTO ascp_scans (project_id, scan_sequence, commit_sha, scan_type, created_at, updated_at)
SELECT 278964, gs, md5(random()::text), 0,
now() - (interval '1 day' * gs * 30),
now() - (interval '1 day' * gs * 30)
FROM generate_series(1, 5) gs;
-- Insert 145 incremental scans referencing the latest full scan
INSERT INTO ascp_scans (project_id, scan_sequence, commit_sha, scan_type, base_scan_id, base_commit_sha, created_at, updated_at)
SELECT 278964, 5 + gs, md5(random()::text), 1,
(SELECT id FROM ascp_scans WHERE project_id = 278964 AND scan_type = 0 ORDER BY scan_sequence DESC LIMIT 1),
md5(random()::text),
now() - (interval '1 hour' * gs),
now() - (interval '1 hour' * gs)
FROM generate_series(1, 145) gs;
-- Verify: expect 5 full + 145 incremental = 150 total
SELECT count(*), scan_type FROM ascp_scans WHERE project_id = 278964 GROUP BY scan_type;
1. List scans for a project (ScansResolver / ScansFinder)
SELECT "ascp_scans".*
FROM "ascp_scans"
WHERE "ascp_scans"."project_id" = 278964
ORDER BY "ascp_scans"."scan_sequence" DESC;
Used by: ScansResolver via ScansFinder#execute. Always scoped to a single project. Results are paginated via GraphQL connection (default_max_page_size: 100, adds LIMIT 101).
Query plan (without pagination) | Query plan (with LIMIT 101):
Limit (cost=0.14..8.86 rows=101 width=112) (actual time=0.031..0.049 rows=101 loops=1)
Buffers: shared hit=7
-> Index Scan Backward using index_ascp_scans_on_project_id_and_scan_sequence on public.ascp_scans
(cost=0.14..13.08 rows=150 width=112) (actual time=0.030..0.041 rows=101 loops=1)
Index Cond: (ascp_scans.project_id = 278964)
Buffers: shared hit=7
2. List scans with scan_type filter (ScansResolver / ScansFinder)
SELECT "ascp_scans".*
FROM "ascp_scans"
WHERE "ascp_scans"."project_id" = 278964
AND "ascp_scans"."scan_type" = 0
ORDER BY "ascp_scans"."scan_sequence" DESC;
Used by: ScansResolver when scanType argument is provided. Paginated via GraphQL connection (LIMIT 101).
With scan_type = 0 (full scans, ~5 rows): Uses (project_id, scan_type) index + in-memory quicksort (trivial for <10 rows).
Limit (cost=4.62..4.63 rows=5 width=112) (actual time=0.059..0.061 rows=5 loops=1)
Buffers: shared hit=11
-> Sort (cost=4.62..4.63 rows=5 width=112) (actual time=0.057..0.059 rows=5 loops=1)
Sort Key: ascp_scans.scan_sequence DESC
Sort Method: quicksort Memory: 25kB
-> Index Scan using index_ascp_scans_on_project_id_and_scan_type on public.ascp_scans
(cost=0.14..4.56 rows=5 width=112) (actual time=0.036..0.038 rows=5 loops=1)
Index Cond: ((ascp_scans.project_id = 278964) AND (ascp_scans.scan_type = 0))
With scan_type = 1 (incremental scans, ~145 rows): Planner switches to backward index scan on (project_id, scan_sequence) with scan_type as post-filter. No in-memory sort -- early termination at LIMIT.
Limit (cost=0.14..9.42 rows=101 width=112) (actual time=0.038..0.060 rows=101 loops=1)
Buffers: shared hit=7
-> Index Scan Backward using index_ascp_scans_on_project_id_and_scan_sequence on public.ascp_scans
(cost=0.14..13.46 rows=145 width=112) (actual time=0.037..0.053 rows=101 loops=1)
Index Cond: (ascp_scans.project_id = 278964)
Filter: (ascp_scans.scan_type = 1)
Rows Removed by Filter: 0
3. Get next scan_sequence (CreateScanService)
SELECT MAX("ascp_scans"."scan_sequence")
FROM "ascp_scans"
WHERE "ascp_scans"."project_id" = 278964;
Used by: CreateScanService inside an exclusive lease lock to atomically determine the next sequence number.
Result (cost=0.23..0.24 rows=1 width=4) (actual time=0.061..0.061 rows=1 loops=1)
Buffers: shared hit=5
InitPlan 1
-> Limit (cost=0.14..0.23 rows=1 width=4) (actual time=0.056..0.056 rows=1 loops=1)
Buffers: shared hit=5
-> Index Only Scan Backward using index_ascp_scans_on_project_id_and_scan_sequence on public.ascp_scans
(cost=0.14..13.08 rows=150 width=4) (actual time=0.054..0.055 rows=1 loops=1)
Index Cond: (ascp_scans.project_id = 278964)
Heap Fetches: 1
Buffers: shared hit=5
4. Find base scan for project (CreateScanService)
SELECT "ascp_scans".*
FROM "ascp_scans"
WHERE "ascp_scans"."project_id" = 278964
AND "ascp_scans"."id" = 1
LIMIT 1;
Used by: CreateScanService#resolve_base_scan when creating incremental scans. Scoped to project to prevent cross-project references.
Limit (cost=0.14..3.17 rows=1 width=112) (actual time=0.023..0.024 rows=1 loops=1)
Buffers: shared hit=5
-> Index Scan using ascp_scans_pkey on public.ascp_scans
(cost=0.14..3.17 rows=1 width=112) (actual time=0.021..0.022 rows=1 loops=1)
Index Cond: (ascp_scans.id = 1)
Filter: (ascp_scans.project_id = 278964)
Rows Removed by Filter: 0
Buffers: shared hit=5
5. Insert scan (CreateScanService)
INSERT INTO "ascp_scans"
("project_id", "scan_sequence", "commit_sha", "scan_type", "created_at", "updated_at")
VALUES (278964, 151, 'abc123def456', 1, now(), now())
RETURNING "id";
Used by: CreateScanService after computing the next scan_sequence.
ModifyTable on public.ascp_scans (cost=0.00..0.02 rows=1 width=110) (actual time=0.330..0.331 rows=1 loops=1)
Buffers: shared hit=144 dirtied=7
WAL: records=7 fpi=6 bytes=27806
-> Result (cost=0.00..0.02 rows=1 width=110) (actual time=0.078..0.078 rows=1 loops=1)
Buffers: shared hit=15 dirtied=1
WAL: records=1 fpi=0 bytes=99
Trigger RI_ConstraintTrigger_c_321062408 for constraint fk_16efa16ef2: time=0.048 calls=1
Index usage summary:
- Queries 1, 3 use the composite index
index_ascp_scans_on_project_id_and_scan_sequence(backward scan for ordering, forward scan for MAX). - Query 2 benefits from
index_ascp_scans_on_project_id_and_scan_type. - Query 4 uses the primary key index (id) with project_id filter.
- Query 5 (INSERT) is protected by the unique index on
(project_id, scan_sequence).
How to set up and validate locally
- Ensure the ASCP scans table exists (from MR1)
- Start GDK
- Test GraphQL queries:
# Query scans for a project
query {
project(fullPath: "your/project") {
ascpScans {
nodes {
id
scanSequence
commitSha
scanType
createdAt
}
}
}
}
# Optional: filter by scan type
query {
project(fullPath: "your/project") {
ascpScans(scanType: FULL) {
nodes {
id
scanType
}
}
}
}
# Create a scan
mutation {
ascpScanCreate(input: {
projectPath: "your/project"
commitSha: "abc123"
scanType: FULL
}) {
scan {
id
scanSequence
scanType
}
errors
}
}
- Run specs:
bundle exec rspec ee/spec/graphql/types/security/ascp/
bundle exec rspec ee/spec/graphql/resolvers/security/ascp/
bundle exec rspec ee/spec/graphql/mutations/security/ascp/
bundle exec rspec ee/spec/finders/security/ascp/
bundle exec rspec ee/spec/policies/security/ascp/
bundle exec rspec ee/spec/services/security/ascp/
bundle exec rspec ee/spec/requests/api/graphql/security/ascp/
bundle exec rspec ee/spec/requests/api/graphql/mutations/security/ascp/
MR acceptance checklist
- Tests added for new functionality
- Documentation updated (GraphQL reference auto-generated)
- Database queries and index usage documented
- postgres.ai execution plans
- Changelog entry (if needed)