feat(ascp): Add GraphQL API for ASCP components and security contexts

Summary

Implement GraphQL API for ASCP components and security contexts, exposing the tables from the migration MR.

New GraphQL Types:

  • AscpComponent - Business context components with dependencies
  • AscpSecurityContext - Security context per component
  • AscpSecurityGuideline - Security guidelines (via connection)
  • AscpSeverity - Severity levels enum
  • AscpSecurityGuidelineInput - Input type for guidelines

New Mutations:

  • AscpComponentCreate - Create a business component
  • AscpSecurityContextCreate - Create security context with guidelines

New Resolver:

  • ComponentsResolver - Query components with title/sub_directory filtering

Authorization:

  • Read: Developer+ (direct role rule in ProjectPolicy)
  • Write: Maintainer+ (direct role rule in ProjectPolicy)
  • Auditor: Read access (explicit rule)
  • License gate: security_dashboard required

https://gitlab.com/gitlab-org/gitlab/-/issues/588789

Patterns Applied (from Scans MR review)

This MR follows the patterns established in the merged Scans MR (!222320 (merged)) and LESSONS_LEARNED.md:

  • EntityAction mutation naming (AscpComponentCreate, not AscpCreateComponent)
  • FindsProject + authorized_find! in mutations
  • Business logic extracted to service classes (CreateComponentService, CreateSecurityContextService)
  • Defense-in-depth: Ability.allowed? checks in services
  • Pure delegation in resource policies (ComponentPolicy, SecurityContextPolicy)
  • Direct role-based permissions in ProjectPolicy (not chained through intermediate abilities)
  • Integration tests with post_graphql/post_graphql_mutation (deprecated resolve() pattern removed)
  • Static schema unit tests only (fields/arguments/graphql_name)
  • PolicyHelpers in policy specs with expect_allowed/expect_disallowed
  • experiment tag on all new fields with current milestone
  • Permission YAMLs with only name and description

Database

Queries introduced by this MR

All queries operate on ascp_components, ascp_security_contexts, and ascp_security_guidelines tables which have the following indexes (from the table migration MR):

  • idx_ascp_components_on_project_scan_subdir (unique, composite: project_id, scan_id, sub_directory)
  • index on ascp_components (scan_id)
  • idx_ascp_security_contexts_on_project_scan_comp (unique, composite: project_id, scan_id, component_id)
  • index on ascp_security_contexts (component_id), (scan_id)
  • idx_ascp_security_guidelines_on_proj_ctx (composite: project_id, security_context_id)
  • index on ascp_security_guidelines (scan_id), (security_context_id)

Note: These are new tables 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: ~20 business components per project, each with a security context and 3-5 security guidelines.

Seed data queries (click to expand)
-- Insert 2 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, 2) gs;

-- Insert 20 components across different sub-directories
INSERT INTO ascp_components
  (project_id, scan_id, title, sub_directory, description, expected_user_behavior, created_at, updated_at)
SELECT 278964,
  (SELECT id FROM ascp_scans WHERE project_id = 278964 ORDER BY scan_sequence DESC LIMIT 1),
  (ARRAY['Authentication Service', 'Authorization Engine', 'Payment Gateway',
    'User Profile Manager', 'Notification System', 'File Upload Handler',
    'Search Engine', 'API Gateway', 'Session Manager', 'Audit Logger',
    'Email Service', 'Webhook Dispatcher', 'Rate Limiter', 'Cache Manager',
    'Job Scheduler', 'Encryption Service', 'OAuth Provider', 'Admin Dashboard',
    'Data Export Module', 'Health Check Monitor'])[gs],
  (ARRAY['app/services/auth', 'app/services/authz', 'app/services/payments',
    'app/models/users', 'app/services/notifications', 'app/controllers/uploads',
    'lib/search', 'app/controllers/api', 'app/services/sessions', 'lib/audit',
    'app/mailers', 'app/services/webhooks', 'lib/rate_limiting', 'lib/cache',
    'app/workers', 'lib/encryption', 'app/services/oauth', 'app/controllers/admin',
    'app/services/exports', 'lib/health'])[gs],
  'Description for component ' || gs,
  'Expected behavior for component ' || gs,
  now() - (interval '1 hour' * gs),
  now() - (interval '1 hour' * gs)
FROM generate_series(1, 20) gs;

-- Insert security contexts (one per component)
INSERT INTO ascp_security_contexts
  (project_id, component_id, scan_id, summary, authentication_model,
   authorization_model, data_sensitivity, created_at, updated_at)
SELECT c.project_id, c.id, c.scan_id,
  'Threat model for ' || c.title, 'JWT tokens', 'RBAC', 'medium', now(), now()
FROM ascp_components c WHERE c.project_id = 278964;

-- Insert 4 guidelines per security context (80 total)
INSERT INTO ascp_security_guidelines
  (project_id, security_context_id, scan_id, name, operation,
   legitimate_use, security_boundary, business_context,
   severity_if_violated, created_at, updated_at)
SELECT sc.project_id, sc.id, sc.scan_id,
  'Guideline ' || g || ' for context ' || sc.id,
  'Operation ' || g, 'Legitimate use ' || g,
  'Security boundary ' || g, 'Business context ' || g,
  g - 1, now(), now()
FROM ascp_security_contexts sc
CROSS JOIN generate_series(1, 4) g
WHERE sc.project_id = 278964;

-- Verify: expect 20 components, 20 security contexts, 80 guidelines
SELECT count(*) FROM ascp_components WHERE project_id = 278964;
SELECT count(*) FROM ascp_security_contexts WHERE project_id = 278964;
SELECT count(*) FROM ascp_security_guidelines WHERE project_id = 278964;

1. List components for a project (ComponentsResolver / ComponentsFinder)

SELECT "ascp_components".*
FROM "ascp_components"
WHERE "ascp_components"."project_id" = 278964
ORDER BY "ascp_components"."id" DESC
LIMIT 101;

Used by: ComponentsResolver via ComponentsFinder#execute. Always scoped to a single project. Results are paginated via GraphQL connection (default_max_page_size: 100, adds LIMIT 101).

Query plan:

Limit  (cost=3.17..3.17 rows=1 width=168) (actual time=0.059..0.061 rows=20 loops=1)
  Buffers: shared hit=8
  ->  Sort  (cost=3.17..3.17 rows=1 width=168) (actual time=0.058..0.059 rows=20 loops=1)
        Sort Key: ascp_components.id DESC
        Sort Method: quicksort  Memory: 30kB
        Buffers: shared hit=8
        ->  Index Scan using idx_ascp_components_on_project_scan_subdir on public.ascp_components
              (cost=0.14..3.16 rows=1 width=168) (actual time=0.022..0.025 rows=20 loops=1)
              Index Cond: (ascp_components.project_id = 278964)
              Buffers: shared hit=5

2. List components with title filter (ComponentsFinder)

SELECT "ascp_components".*
FROM "ascp_components"
WHERE "ascp_components"."project_id" = 278964
  AND "ascp_components"."title" ILIKE '%Auth%'
ORDER BY "ascp_components"."id" DESC
LIMIT 101;

Used by: ComponentsResolver when title argument is provided. Paginated via GraphQL connection.

Query plan:

Limit  (cost=3.17..3.17 rows=1 width=168) (actual time=0.111..0.112 rows=3 loops=1)
  Buffers: shared hit=8
  ->  Sort  (cost=3.17..3.17 rows=1 width=168) (actual time=0.109..0.110 rows=3 loops=1)
        Sort Key: ascp_components.id DESC
        Sort Method: quicksort  Memory: 25kB
        Buffers: shared hit=8
        ->  Index Scan using idx_ascp_components_on_project_scan_subdir on public.ascp_components
              (cost=0.14..3.16 rows=1 width=168) (actual time=0.074..0.083 rows=3 loops=1)
              Index Cond: (ascp_components.project_id = 278964)
              Filter: (ascp_components.title ~~* '%Auth%'::text)
              Rows Removed by Filter: 17
              Buffers: shared hit=5

3. List components with sub_directory filter (ComponentsFinder)

SELECT "ascp_components".*
FROM "ascp_components"
WHERE "ascp_components"."project_id" = 278964
  AND "ascp_components"."sub_directory" = 'app/services/auth'
ORDER BY "ascp_components"."id" DESC
LIMIT 101;

Used by: ComponentsResolver when subDirectory argument is provided.

Query plan:

Limit  (cost=3.17..3.17 rows=1 width=168) (actual time=0.063..0.065 rows=1 loops=1)
  Buffers: shared hit=8
  ->  Sort  (cost=3.17..3.17 rows=1 width=168) (actual time=0.062..0.063 rows=1 loops=1)
        Sort Key: ascp_components.id DESC
        Sort Method: quicksort  Memory: 25kB
        Buffers: shared hit=8
        ->  Index Scan using idx_ascp_components_on_project_scan_subdir on public.ascp_components
              (cost=0.14..3.16 rows=1 width=168) (actual time=0.031..0.032 rows=1 loops=1)
              Index Cond: ((ascp_components.project_id = 278964) AND (ascp_components.sub_directory = 'app/services/auth'::text))
              Buffers: shared hit=5

4. Find component for project (CreateSecurityContextService)

SELECT "ascp_components".*
FROM "ascp_components"
WHERE "ascp_components"."project_id" = 278964
  AND "ascp_components"."id" = 1
LIMIT 1;

Used by: Component.find_for_project in CreateSecurityContextService. Scoped to project to prevent cross-project references.

Query plan:

Limit  (cost=0.14..3.16 rows=1 width=168) (actual time=0.045..0.045 rows=1 loops=1)
  Buffers: shared hit=5
  ->  Index Scan using idx_ascp_components_on_project_scan_subdir on public.ascp_components
        (cost=0.14..3.16 rows=1 width=168) (actual time=0.043..0.043 rows=1 loops=1)
        Index Cond: (ascp_components.project_id = 278964)
        Filter: (ascp_components.id = 1)
        Rows Removed by Filter: 5
        Buffers: shared hit=5

5. Find scan for project (CreateComponentService / CreateSecurityContextService)

SELECT "ascp_scans".*
FROM "ascp_scans"
WHERE "ascp_scans"."project_id" = 278964
  AND "ascp_scans"."id" = 1
LIMIT 1;

Used by: Scan.find_for_project in both CreateComponentService and CreateSecurityContextService. Scoped to project to prevent cross-project references.

Query plan:

Limit  (cost=0.14..3.16 rows=1 width=110) (actual time=0.034..0.034 rows=1 loops=1)
  Buffers: shared hit=5
  ->  Index Scan using index_ascp_scans_on_project_id_and_scan_type on public.ascp_scans
        (cost=0.14..3.16 rows=1 width=110) (actual time=0.032..0.032 rows=1 loops=1)
        Index Cond: (ascp_scans.project_id = 278964)
        Filter: (ascp_scans.id = 1)
        Rows Removed by Filter: 0
        Buffers: shared hit=5

6. Insert component (CreateComponentService)

INSERT INTO "ascp_components"
  ("project_id", "scan_id", "title", "sub_directory", "description",
   "expected_user_behavior", "created_at", "updated_at")
VALUES (278964, 1, 'Test Component', 'app/test', 'Test description',
        'Test behavior', now(), now())
RETURNING "id";

Used by: CreateComponentService after resolving the scan.

Query plan:

ModifyTable on public.ascp_components  (cost=0.00..0.02 rows=1 width=168) (actual time=0.306..0.306 rows=1 loops=1)
  Buffers: shared hit=106 dirtied=5
  WAL: records=5 fpi=4 bytes=5832
  ->  Result  (cost=0.00..0.02 rows=1 width=168) (actual time=0.086..0.086 rows=1 loops=1)
        Buffers: shared hit=15 dirtied=1
        WAL: records=1 fpi=0 bytes=99
Trigger RI_ConstraintTrigger_c_387914317 for constraint fk_749112e620: time=0.694 calls=1

7. Insert security context (CreateSecurityContextService)

INSERT INTO "ascp_security_contexts"
  ("project_id", "component_id", "scan_id", "summary",
   "authentication_model", "authorization_model", "data_sensitivity",
   "created_at", "updated_at")
VALUES (278964, 1, 1, 'Test summary', 'JWT', 'RBAC', 'high', now(), now())
RETURNING "id";

Used by: CreateSecurityContextService inside a SecApplicationRecord.transaction.

Query plan:

ModifyTable on public.ascp_security_contexts  (cost=0.00..0.02 rows=1 width=176) (actual time=0.346..0.347 rows=1 loops=1)
  Buffers: shared hit=117 dirtied=6
  WAL: records=6 fpi=5 bytes=5385
  ->  Result  (cost=0.00..0.02 rows=1 width=176) (actual time=0.117..0.118 rows=1 loops=1)
        Buffers: shared hit=15 dirtied=1
        WAL: records=1 fpi=0 bytes=99
Trigger RI_ConstraintTrigger_c_387914358 for constraint fk_157154ac67: time=0.642 calls=1
Trigger RI_ConstraintTrigger_c_387914365 for constraint fk_6fb0a2b15c: time=0.439 calls=1

8. Insert security guideline (CreateSecurityContextService)

INSERT INTO "ascp_security_guidelines"
  ("project_id", "security_context_id", "scan_id", "name", "operation",
   "legitimate_use", "security_boundary", "business_context",
   "severity_if_violated", "created_at", "updated_at")
VALUES (278964, 1, 1, 'SQL Injection Prevention', 'Database queries',
        'Parameterized queries only', 'User input in SQL',
        'Data integrity risk', 2, now(), now())
RETURNING "id";

Used by: CreateSecurityContextService#create_guidelines. One per guideline in the input array.

Query plan:

ModifyTable on public.ascp_security_guidelines  (cost=0.00..0.02 rows=1 width=210) (actual time=0.341..0.342 rows=1 loops=1)
  Buffers: shared hit=116 dirtied=6
  WAL: records=6 fpi=5 bytes=13921
  ->  Result  (cost=0.00..0.02 rows=1 width=210) (actual time=0.104..0.104 rows=1 loops=1)
        Buffers: shared hit=16 dirtied=1
        WAL: records=1 fpi=0 bytes=99
Trigger RI_ConstraintTrigger_c_387914388 for constraint fk_be2c636993: time=0.626 calls=1
Trigger RI_ConstraintTrigger_c_387914393 for constraint fk_82046cabc2: time=0.485 calls=1

Index usage summary:

Query Index Used Notes
Queries 1, 4 idx_ascp_components_on_project_scan_subdir project_id prefix for scoping
Query 2 Same index + ILIKE post-filter No index on title (ILIKE can't use btree efficiently)
Query 3 Same composite index Both project_id and sub_directory in Index Cond
Query 5 index_ascp_scans_on_project_id_and_scan_type project_id prefix + id filter
Query 6 Unique index protects against duplicate components FK trigger validates scan_id
Query 7 Unique index protects against duplicate contexts FK triggers validate component_id, scan_id
Query 8 Index idx_ascp_security_guidelines_on_proj_ctx FK triggers validate security_context_id, scan_id

How to set up and validate locally

  1. Check out this branch
  2. Ensure ASCP tables exist: bundle exec rails dbconsole then \dt ascp_*
  3. Start GDK
  4. Test GraphQL in GraphiQL (/-/graphql-explorer):
# Query components
query {
  project(fullPath: "your/project") {
    ascpComponents {
      nodes {
        id
        title
        subDirectory
        description
        scan { id }
        securityContext {
          summary
          securityGuidelines {
            nodes { name operation severityIfViolated }
          }
        }
      }
    }
  }
}

# Create a component
mutation {
  ascpComponentCreate(input: {
    projectPath: "your/project"
    title: "Auth Module"
    subDirectory: "app/auth"
    scanId: "gid://gitlab/Security::Ascp::Scan/1"
  }) {
    component { id title }
    errors
  }
}

# Create a security context
mutation {
  ascpSecurityContextCreate(input: {
    projectPath: "your/project"
    componentId: "gid://gitlab/Security::Ascp::Component/1"
    scanId: "gid://gitlab/Security::Ascp::Scan/1"
    summary: "Threat model"
    guidelines: [{
      name: "SQL Injection Prevention"
      operation: "Database queries"
      severityIfViolated: HIGH
    }]
  }) {
    securityContext { id summary }
    errors
  }
}
  1. Run specs:
bundle exec rspec ee/spec/requests/api/graphql/security/ascp/components_spec.rb
bundle exec rspec ee/spec/requests/api/graphql/mutations/security/ascp/
bundle exec rspec ee/spec/graphql/types/security/ascp/
bundle exec rspec ee/spec/graphql/mutations/security/ascp/
bundle exec rspec ee/spec/graphql/resolvers/security/ascp/
bundle exec rspec ee/spec/policies/security/ascp/
bundle exec rspec ee/spec/finders/security/ascp/components_finder_spec.rb
bundle exec rspec ee/spec/services/security/ascp/

MR acceptance checklist

  • Reviewed LESSONS_LEARNED.md before starting
  • Mutations use EntityAction naming (e.g., AscpComponentCreate)
  • Business logic extracted to service classes
  • Defense-in-depth: Ability.allowed? checks in services
  • Permissions use direct role rules (not chained)
  • Integration tests (not deprecated resolver unit tests)
  • Policy specs use PolicyHelpers with table syntax
  • ProjectPolicy specs added for new permissions
  • Database queries documented with postgres.ai plans
  • GraphQL artifacts recompiled (docs + introspection)
  • experiment tag on new fields with current milestone

EE: true

Edited by Meir Benayoun

Merge request reports

Loading