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) and ScanTypeEnum (AscpScanType) GraphQL types
  • Add ScansResolver for project-level scan queries with filtering by scan type
  • Add ScanCreate mutation with automatic scan_sequence calculation via exclusive lease
  • Add CreateScanService with atomic scan_sequence generation
  • Add ScanPolicy with read/create permissions delegating to project
  • Add permission definitions (read_ascp_scan, create_ascp_scan)
  • Extend ProjectType with ascp_scans field (experiment, milestone 18.10)
  • Add has_many :security_ascp_scans association to Project model
  • Add by_scan_type scope to Scan model
  • Register security_ascp_scans in import/export model configuration
  • Add comprehensive specs (integration, unit, policy, finder, service)

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_sha
  • index_ascp_scans_on_base_scan_id
  • index_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).

Query plan:

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.

Query plan:

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.

Query plan:

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.

Query plan:

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.

Query plan:

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

  1. Ensure the ASCP scans table exists (from MR1)
  2. Start GDK
  3. 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
  }
}
  1. 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)
Edited by Meir Benayoun

Merge request reports

Loading