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_dashboardrequired
Related Issue
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:
-
EntityActionmutation naming (AscpComponentCreate, notAscpCreateComponent) -
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(deprecatedresolve()pattern removed) - Static schema unit tests only (fields/arguments/graphql_name)
-
PolicyHelpersin policy specs withexpect_allowed/expect_disallowed -
experimenttag on all new fields with current milestone - Permission YAMLs with only
nameanddescription
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).
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.
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.
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.
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.
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.
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.
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.
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
- Check out this branch
- Ensure ASCP tables exist:
bundle exec rails dbconsolethen\dt ascp_* - Start GDK
- 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
}
}
- 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
EntityActionnaming (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
PolicyHelperswith table syntax - ProjectPolicy specs added for new permissions
- Database queries documented with postgres.ai plans
- GraphQL artifacts recompiled (docs + introspection)
-
experimenttag on new fields with current milestone
EE: true