Spec 095: Graph contracts registry completeness + registry-backed call sites (#114)

Implements Spec 095.

What changed
- Registers 4 Graph resources in the contract registry (plus required subresource template)
- Refactors in-scope call sites to resolve Graph paths via the registry (no ad-hoc endpoints for these resources)
- Adds/updates regression tests to prevent future drift (missing registry entries and endpoint string reintroduction)
- Includes full SpecKit artifacts under specs/095-graph-contracts-registry-completeness/

Validation
- Focused tests:
  - `vendor/bin/sail artisan test --compact tests/Feature/Graph/GraphContractRegistryCoverageSpec095Test.php tests/Feature/SettingsCatalogDefinitionResolverTest.php`

Notes
- Livewire v4.0+ / Filament v5 compliant (no UI changes).
- No new routes/pages; no RBAC model changes.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #114
This commit is contained in:
ahmido 2026-02-15 15:02:27 +00:00
parent bda1d90fc4
commit eec93b510a
18 changed files with 819 additions and 25 deletions

View File

@ -28,6 +28,7 @@ ## Active Technologies
- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -47,8 +48,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 095-graph-contracts-registry-completeness: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface`
- 090-action-surface-contract-compliance: Added PHP 8.4.15
- 087-legacy-runs-removal: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4
- 088-remove-tenant-graphoptions-legacy: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -49,6 +49,89 @@ public function directoryRoleDefinitionsListPath(): string
return '/'.ltrim($resource, '/');
}
public function configurationPolicyTemplatePolicyType(): string
{
return 'configurationPolicyTemplate';
}
public function configurationPolicyTemplateListPath(): string
{
$resource = $this->resourcePath($this->configurationPolicyTemplatePolicyType()) ?? 'deviceManagement/configurationPolicyTemplates';
return '/'.ltrim($resource, '/');
}
public function configurationPolicyTemplateItemPath(string $templateId): string
{
return sprintf('%s/%s', $this->configurationPolicyTemplateListPath(), urlencode($templateId));
}
public function configurationPolicyTemplateSettingTemplatesPath(string $templateId): string
{
$path = $this->subresourcePath(
$this->configurationPolicyTemplatePolicyType(),
'settingTemplates',
['{id}' => $templateId]
);
if (! is_string($path) || $path === '') {
return sprintf('%s/%s/settingTemplates', $this->configurationPolicyTemplateListPath(), urlencode($templateId));
}
return '/'.ltrim($path, '/');
}
public function settingsCatalogDefinitionPolicyType(): string
{
return 'settingsCatalogDefinition';
}
public function settingsCatalogDefinitionListPath(): string
{
$resource = $this->resourcePath($this->settingsCatalogDefinitionPolicyType()) ?? 'deviceManagement/configurationSettings';
return '/'.ltrim($resource, '/');
}
public function settingsCatalogDefinitionItemPath(string $definitionId): string
{
return sprintf('%s/%s', $this->settingsCatalogDefinitionListPath(), urlencode($definitionId));
}
public function settingsCatalogCategoryPolicyType(): string
{
return 'settingsCatalogCategory';
}
public function settingsCatalogCategoryListPath(): string
{
$resource = $this->resourcePath($this->settingsCatalogCategoryPolicyType()) ?? 'deviceManagement/configurationCategories';
return '/'.ltrim($resource, '/');
}
public function settingsCatalogCategoryItemPath(string $categoryId): string
{
return sprintf('%s/%s', $this->settingsCatalogCategoryListPath(), urlencode($categoryId));
}
public function rbacRoleAssignmentPolicyType(): string
{
return 'rbacRoleAssignment';
}
public function rbacRoleAssignmentListPath(): string
{
$resource = $this->resourcePath($this->rbacRoleAssignmentPolicyType()) ?? 'deviceManagement/roleAssignments';
return '/'.ltrim($resource, '/');
}
public function rbacRoleAssignmentItemPath(string $assignmentId): string
{
return sprintf('%s/%s', $this->rbacRoleAssignmentListPath(), urlencode($assignmentId));
}
/**
* @return array<string, mixed>
*/

View File

@ -4,6 +4,7 @@
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Providers\MicrosoftGraphOptionsResolver;
use Illuminate\Support\Arr;
@ -26,6 +27,7 @@ class ConfigurationPolicyTemplateResolver
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly GraphContractRegistry $contracts,
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
) {}
@ -161,7 +163,7 @@ public function getTemplate(Tenant $tenant, string $templateId, array $graphOpti
$this->graphOptionsResolver->resolveForTenant($tenant),
Arr::except($graphOptions, ['platform']),
);
$path = sprintf('/deviceManagement/configurationPolicyTemplates/%s', urlencode($templateId));
$path = $this->contracts->configurationPolicyTemplateItemPath($templateId);
$response = $this->graphClient->request('GET', $path, $context);
if ($response->failed()) {
@ -201,7 +203,7 @@ public function listTemplatesByFamily(Tenant $tenant, string $templateFamily, ar
],
]);
$response = $this->graphClient->request('GET', '/deviceManagement/configurationPolicyTemplates', $context);
$response = $this->graphClient->request('GET', $this->contracts->configurationPolicyTemplateListPath(), $context);
if ($response->failed()) {
return $this->familyCache[$tenantKey][$cacheKey] = [
@ -240,7 +242,7 @@ public function fetchTemplateSettingDefinitionIds(Tenant $tenant, string $templa
],
]);
$path = sprintf('/deviceManagement/configurationPolicyTemplates/%s/settingTemplates', urlencode($templateId));
$path = $this->contracts->configurationPolicyTemplateSettingTemplatesPath($templateId);
$response = $this->graphClient->request('GET', $path, $context);
if ($response->failed()) {

View File

@ -5,6 +5,7 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\Providers\ProviderReasonCodes;
@ -16,6 +17,7 @@ class RbacHealthService
{
public function __construct(
private readonly GraphClientInterface $graph,
private readonly GraphContractRegistry $contracts,
private readonly ?ProviderConnectionResolver $providerConnections = null,
private readonly ?ProviderGateway $providerGateway = null,
) {}
@ -54,7 +56,8 @@ public function check(Tenant $tenant): array
}
if ($tenant->rbac_role_assignment_id) {
$response = $this->graph->request('GET', "deviceManagement/roleAssignments/{$tenant->rbac_role_assignment_id}", $context);
$assignmentPath = $this->contracts->rbacRoleAssignmentItemPath($tenant->rbac_role_assignment_id);
$response = $this->graph->request('GET', $assignmentPath, $context);
if ($response->successful()) {
$assignment = $response->data;

View File

@ -6,6 +6,7 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\Providers\ProviderReasonCodes;
@ -20,6 +21,7 @@ class RbacOnboardingService
public function __construct(
private readonly GraphClientInterface $graph,
private readonly GraphContractRegistry $contracts,
private readonly AuditLogger $auditLogger,
private readonly ?ProviderConnectionResolver $providerConnections = null,
private readonly ?ProviderGateway $providerGateway = null,
@ -408,8 +410,9 @@ private function ensureRoleAssignment(string $roleDefinitionId, string $groupId,
$desiredScopes = $scope === 'scope_group' && $scopeGroupId
? [$scopeGroupId]
: ['/'];
$roleAssignmentsPath = $this->contracts->rbacRoleAssignmentListPath();
$assignments = $this->graph->request('GET', 'deviceManagement/roleAssignments', [
$assignments = $this->graph->request('GET', $roleAssignmentsPath, [
'query' => [
'$select' => 'id,displayName,resourceScopes,members',
'$expand' => 'roleDefinition($select=id,displayName)',
@ -421,7 +424,8 @@ private function ensureRoleAssignment(string $roleDefinitionId, string $groupId,
$error = $this->extractErrorMessage($assignments->errors, $assignments->data);
throw new RuntimeException(sprintf(
'step=listRoleAssignments path=/deviceManagement/roleAssignments status=%s error=%s',
'step=listRoleAssignments path=%s status=%s error=%s',
$roleAssignmentsPath,
$status,
$error
));
@ -450,7 +454,8 @@ private function ensureRoleAssignment(string $roleDefinitionId, string $groupId,
return ['id' => $matching['id'] ?? null, 'action' => 'role_assignment_exists'];
}
$update = $this->graph->request('PATCH', "deviceManagement/roleAssignments/{$matching['id']}", [
$updatePath = $this->contracts->rbacRoleAssignmentItemPath((string) $matching['id']);
$update = $this->graph->request('PATCH', $updatePath, [
'json' => ['resourceScopes' => $desiredScopes],
] + $context);
@ -469,8 +474,8 @@ private function ensureRoleAssignment(string $roleDefinitionId, string $groupId,
}
throw new RuntimeException(sprintf(
'step=updateRoleAssignment path=/deviceManagement/roleAssignments/%s status=%s error=%s',
$matching['id'],
'step=updateRoleAssignment path=%s status=%s error=%s',
$updatePath,
$update->status ?? 'unknown',
$error
));
@ -479,7 +484,7 @@ private function ensureRoleAssignment(string $roleDefinitionId, string $groupId,
return ['id' => $matching['id'] ?? null, 'action' => 'role_assignment_updated'];
}
$create = $this->graph->request('POST', 'deviceManagement/roleAssignments', [
$create = $this->graph->request('POST', $roleAssignmentsPath, [
'json' => [
'displayName' => "TenantPilot RBAC - {$roleDefinitionId}",
'description' => 'TenantPilot automated RBAC setup',
@ -520,7 +525,8 @@ private function ensureRoleAssignment(string $roleDefinitionId, string $groupId,
}
$details = sprintf(
'step=createRoleAssignment path=/deviceManagement/roleAssignments status=%s error=%s',
'step=createRoleAssignment path=%s status=%s error=%s',
$roleAssignmentsPath,
$create->status ?? 'unknown',
$error
);
@ -561,7 +567,8 @@ private function hydrateAssignmentMembers(array $assignments, array $context): a
return $assignment;
}
$membersResponse = $this->graph->request('GET', "deviceManagement/roleAssignments/{$assignment['id']}", [
$membersPath = $this->contracts->rbacRoleAssignmentItemPath((string) $assignment['id']);
$membersResponse = $this->graph->request('GET', $membersPath, [
'query' => [
'$select' => 'id,displayName,resourceScopes,members',
'$expand' => 'roleDefinition($select=id,displayName)',

View File

@ -4,6 +4,7 @@
use App\Models\SettingsCatalogCategory;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
@ -14,7 +15,8 @@ class SettingsCatalogCategoryResolver
private const CACHE_TTL = 3600; // 1 hour in memory
public function __construct(
private readonly GraphClientInterface $graphClient
private readonly GraphClientInterface $graphClient,
private readonly GraphContractRegistry $contracts,
) {}
/**
@ -138,12 +140,12 @@ private function fetchFromGraph(array $categoryIds): array
$categories = [];
// Fetch each category individually
// Endpoint: /deviceManagement/configurationCategories/{categoryId}
foreach ($categoryIds as $categoryId) {
try {
$path = $this->contracts->settingsCatalogCategoryItemPath($categoryId);
$response = $this->graphClient->request(
'GET',
"/deviceManagement/configurationCategories/{$categoryId}"
$path
);
if ($response->successful() && isset($response->data)) {

View File

@ -4,6 +4,7 @@
use App\Models\SettingsCatalogDefinition;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@ -15,7 +16,8 @@ class SettingsCatalogDefinitionResolver
private const MEMORY_CACHE_PREFIX = 'settings_catalog_def_';
public function __construct(
private GraphClientInterface $graphClient
private GraphClientInterface $graphClient,
private GraphContractRegistry $contracts,
) {}
/**
@ -162,7 +164,6 @@ private function fetchFromGraph(array $definitionIds): array
// Note: Microsoft Graph API does not support "in" operator for $filter.
// We fetch each definition individually.
// Endpoint: /deviceManagement/configurationSettings/{definitionId}
foreach ($definitionIds as $definitionId) {
// Skip template IDs with placeholders - these are not real definition IDs
if (str_contains($definitionId, '{') || str_contains($definitionId, '}')) {
@ -172,9 +173,10 @@ private function fetchFromGraph(array $definitionIds): array
}
try {
$path = $this->contracts->settingsCatalogDefinitionItemPath($definitionId);
$response = $this->graphClient->request(
'GET',
"/deviceManagement/configurationSettings/{$definitionId}"
$path
);
if ($response->successful() && isset($response->data)) {

View File

@ -38,6 +38,35 @@
'allowed_select' => ['id', 'complianceState'],
'allowed_expand' => [],
],
'configurationPolicyTemplate' => [
'resource' => 'deviceManagement/configurationPolicyTemplates',
'allowed_select' => ['id', 'displayName', 'displayVersion', 'templateFamily'],
'allowed_expand' => [],
'subresources' => [
'settingTemplates' => [
'path' => 'deviceManagement/configurationPolicyTemplates/{id}/settingTemplates',
'collection' => true,
'paging' => true,
'allowed_select' => [],
'allowed_expand' => ['settingDefinitions'],
],
],
],
'settingsCatalogDefinition' => [
'resource' => 'deviceManagement/configurationSettings',
'allowed_select' => ['id', 'displayName', 'description', 'helpText', 'categoryId', 'uxBehavior'],
'allowed_expand' => [],
],
'settingsCatalogCategory' => [
'resource' => 'deviceManagement/configurationCategories',
'allowed_select' => ['id', 'displayName', 'description'],
'allowed_expand' => [],
],
'rbacRoleAssignment' => [
'resource' => 'deviceManagement/roleAssignments',
'allowed_select' => ['id', 'displayName', 'resourceScopes', 'members'],
'allowed_expand' => ['roleDefinition($select=id,displayName)'],
],
'deviceConfiguration' => [
'resource' => 'deviceManagement/deviceConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Graph Contracts Registry Completeness
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-15
**Feature**: [specs/095-graph-contracts-registry-completeness/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass: Spec is ready for `/speckit.plan`.
- Note: The spec includes internal contract registry identifiers to keep requirements testable and unambiguous; it still avoids implementation/stack details and external endpoint strings.

View File

@ -0,0 +1,86 @@
openapi: 3.0.0
info:
title: TenantPilot — Microsoft Graph deviceManagement contracts (Spec 095)
version: 0.1.0
description: >
Minimal external API contract documentation for the Microsoft Graph resources
governed by Spec 095 (Graph Contracts Registry Completeness).
servers:
- url: https://graph.microsoft.com/v1.0
paths:
/deviceManagement/configurationPolicyTemplates:
get:
summary: List configuration policy templates
responses:
'200':
description: OK
/deviceManagement/configurationPolicyTemplates/{templateId}/settingTemplates:
get:
summary: List setting templates for a configuration policy template
parameters:
- name: templateId
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
/deviceManagement/configurationSettings/{settingId}:
get:
summary: Get a configuration setting definition
parameters:
- name: settingId
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
/deviceManagement/configurationCategories/{categoryId}:
get:
summary: Get a configuration category
parameters:
- name: categoryId
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
/deviceManagement/roleAssignments:
get:
summary: List role assignments
responses:
'200':
description: OK
post:
summary: Create a role assignment
responses:
'201':
description: Created
/deviceManagement/roleAssignments/{roleAssignmentId}:
get:
summary: Get a role assignment
parameters:
- name: roleAssignmentId
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
patch:
summary: Update a role assignment
parameters:
- name: roleAssignmentId
in: path
required: true
schema:
type: string
responses:
'200':
description: OK

View File

@ -0,0 +1,22 @@
# Phase 1 — Data Model: Graph Contracts Registry Completeness
## Summary
This feature introduces **no new database entities** and does not modify any existing schema.
## Affected “Entities” (Configuration-only)
Although no database model changes occur, the feature affects the following configuration concepts:
- **Graph Contract Type**: A named entry in `config/graph_contracts.php` representing a Microsoft Graph resource.
- **Graph Contract Subresource**: A named sub-path template belonging to a contract type (used to model nested resources).
## Ownership & Scope
- **Ownership**: Workspace scope.
- **Persistence**: Configuration only; no new tables/records.
## Validation Rules
- Contract type identifiers must be stable and used consistently in code.
- Resource paths must be representable via the registry and reusable by call sites.

View File

@ -0,0 +1,125 @@
# Implementation Plan: Graph Contracts Registry Completeness
**Branch**: `095-graph-contracts-registry-completeness` | **Date**: 2026-02-15 | **Spec**: [specs/095-graph-contracts-registry-completeness/spec.md](spec.md)
**Input**: Feature specification from [specs/095-graph-contracts-registry-completeness/spec.md](spec.md)
## Summary
This change closes governance gaps in the Microsoft Graph contract registry by explicitly registering four Graph resources already used by the product (templates, settings catalog definitions, categories, role assignments), refactoring a small set of known call sites to use registry-backed paths, and adding regression tests to prevent future “untracked” Graph usage.
Clarified constraints:
- Enforce registry-backed paths only for these four resources and the five known call sites.
- Acceptance evidence is automated Pest tests only (no live tenant required).
- Do not expand scope if additional missing resources are discovered.
## Technical Context
**Language/Version**: PHP 8.4.x
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface`
**Storage**: PostgreSQL (via Laravel Sail)
**Testing**: Pest v4 (Laravel test runner via Sail)
**Target Platform**: Docker (Laravel Sail) for local dev; container-based deploy (Dokploy)
**Project Type**: Web application (Laravel)
**Performance Goals**: N/A (no runtime hot path changes intended)
**Constraints**:
- No new dependencies.
- No new UI/routes.
- Do not require a live tenant for acceptance.
- Keep change bounded to the four resources + five known call sites.
**Scale/Scope**: Small refactor + config change + targeted regression tests.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS (no inventory/snapshot changes).
- Read/write separation: PASS (no new write workflows).
- Single contract path to Graph: PASS (this feature strengthens the contract registry and prevents ad-hoc endpoints).
- Deterministic capabilities: N/A (no capability derivation changes).
- RBAC-UX: PASS (no authorization model or UI surfaces changed).
- Workspace/tenant isolation: PASS (no new cross-tenant reads/writes; registry changes do not imply access).
- Run observability: PASS (no new long-running operations; tests-only acceptance).
- Data minimization & safe logging: PASS (no new payload logging).
- Badge semantics (BADGE-001): N/A (no badges).
- Filament UI Action Surface Contract: N/A (no Filament resources/pages modified).
## Project Structure
### Documentation (this feature)
```text
specs/095-graph-contracts-registry-completeness/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
└── checklists/
```
### Source Code (repository root)
```text
app/
├── Services/
│ ├── Graph/
│ └── Intune/
config/
├── graph_contracts.php
tests/
└── Feature/
```
**Structure Decision**: Laravel monolith. Changes are limited to `config/graph_contracts.php`, small helpers under `app/Services/Graph`, a handful of service call sites under `app/Services/Intune`, and a new targeted Pest test.
## Phase 0 — Outline & Research
### Unknowns / Items to Validate
None required to proceed; the spec is bounded and based on known call sites.
### Research Outputs
- Create [specs/095-graph-contracts-registry-completeness/research.md](research.md) documenting:
- Contract registry patterns used in this repo.
- Drift-check enumeration behavior (top-level resources).
- Test strategy for preventing endpoint string regressions.
## Phase 1 — Design & Contracts
### Data Model
- No new database entities.
- Create [specs/095-graph-contracts-registry-completeness/data-model.md](data-model.md) documenting “no new entities” explicitly.
### Contracts
- Create minimal external API contract documentation under `contracts/` describing the four affected Microsoft Graph endpoints.
- Output: `contracts/graph-deviceManagement-contracts.yaml`.
### Quickstart
- Create [specs/095-graph-contracts-registry-completeness/quickstart.md](quickstart.md) showing how to run the focused tests via Sail.
### Agent Context Update
- Run `.specify/scripts/bash/update-agent-context.sh copilot`.
### Constitution Re-check (post design)
- Expected: still PASS (no UI, no RBAC, no long-running ops).
## Phase 2 — Implementation Plan (no code yet)
1. Add/verify contract registry entries for the four resources in `config/graph_contracts.php`.
2. Ensure contract registry supports a subresource template for “Configuration Policy Template → setting templates”.
3. Refactor the five in-scope call sites to resolve Graph paths via the registry (no hardcoded endpoint substrings for these resources).
4. Add regression tests:
- Registry completeness for the four resources + required subresource template.
- String-guard checks for the five in-scope files to prevent reintroducing hardcoded endpoints.
5. Run formatting: `vendor/bin/sail bin pint --dirty`.
6. Run focused tests via Sail (acceptance evidence): `vendor/bin/sail artisan test --compact` with the new/updated test file(s).
## Complexity Tracking
No constitution violations expected; no complexity exemptions required.

View File

@ -0,0 +1,23 @@
# Quickstart: Graph Contracts Registry Completeness
## Goal
Validate that the Microsoft Graph contract registry includes the four specified resources and that the in-scope call sites use registry-backed paths.
## Prerequisites
- Laravel Sail is available.
## Run the focused tests (acceptance evidence)
- Run the new/updated tests:
- `vendor/bin/sail artisan test --compact`
## Formatting
- Apply formatting to changed files:
- `vendor/bin/sail bin pint --dirty`
## Notes
- A live tenant is not required for acceptance evidence for this spec.

View File

@ -0,0 +1,40 @@
# Phase 0 — Research: Graph Contracts Registry Completeness
## Decisions
### Decision: Scope of enforcement
- **Decision**: Enforce “registry-backed paths” only for the four specified Graph resources and the five known call sites.
- **Rationale**: Keeps the change bounded and reviewable while addressing the concrete governance gap.
- **Alternatives considered**:
- Enforce across the entire codebase (rejected: scope explosion and higher regression risk).
### Decision: Acceptance evidence
- **Decision**: Pest tests passing are sufficient acceptance evidence.
- **Rationale**: Reproducible in CI/local without requiring a live tenant or delegated auth.
- **Alternatives considered**:
- Require drift-check command output (rejected: can require tenant setup and auth).
### Decision: Handling additional discoveries
- **Decision**: Do not expand scope beyond the four specified resources.
- **Rationale**: Prevents “scope creep by implementation”. Additional gaps can be handled via a follow-up spec.
- **Alternatives considered**:
- Expand to additional resources found during implementation (rejected: unbounded).
## Best Practices / Patterns (Repo-specific)
- **Contract registry**: Graph endpoint resources are centrally declared in `config/graph_contracts.php` and should be considered the source of truth.
- **Graph client**: All Graph calls route through `GraphClientInterface`.
- **Governance goal**: Feature code should avoid ad-hoc endpoint strings for governed resources.
## Testing Strategy
- **Registry completeness test**: Assert the four resources are registered and the template subresource is representable.
- **Regression guard**: Assert the five in-scope files do not contain hardcoded endpoint substrings for the governed resources.
## Notes
- This feature makes no changes to RBAC, UI, database schema, or operations observability.
## Follow-ups (out of scope)
- None identified yet.

View File

@ -0,0 +1,125 @@
# Feature Specification: Graph Contracts Registry Completeness
**Feature Branch**: `095-graph-contracts-registry-completeness`
**Created**: 2026-02-15
**Status**: Draft
**Input**: Ensure Microsoft Graph resources already used by the product are explicitly registered in the apps contract registry, and ensure call sites use the registry rather than ad-hoc paths. Add regression tests so these resources cant silently become “untracked” again.
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**: None (no new user-facing pages or routes)
- **Data Ownership**: No new persistent records; registry metadata only
- **RBAC**: No new permissions; behavior must not expand access beyond existing authorization and tenant isolation
## Clarifications
### Session 2026-02-15
- Q: Which enforcement scope do you want for “no freeform Graph paths”? → A: Enforce registry-backed paths only for the 4 specified resources (and the call sites that touch them).
- Q: What evidence should be required for acceptance? → A: Pest regression tests passing is sufficient evidence.
- Q: Which code locations should be considered “in scope” for the regression guard? → A: ConfigurationPolicyTemplateResolver, SettingsCatalogDefinitionResolver, SettingsCatalogCategoryResolver, RbacOnboardingService, and RbacHealthService.
- Q: If we discover additional unregistered Graph resources during implementation, what should we do? → A: Do not expand scope; register only the 4 specified resources (extra findings become a follow-up spec).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Drift coverage is complete (Priority: P1)
As a maintainer, I need the contract drift check to cover the Graph resources we already depend on, so that operational “drift” detection is trustworthy and doesnt miss critical resources.
**Why this priority**: Drift-check coverage gaps undermine safety and auditability across multiple other workflows.
**Independent Test**: A test can assert that the registry explicitly includes the required resources (the same registry enumeration the drift check relies on).
**Acceptance Scenarios**:
1. **Given** the contract registry, **When** it is evaluated for registered resources, **Then** it includes:
- Configuration Policy Templates
- Configuration Settings (settings catalog definitions)
- Configuration Categories
- RBAC Role Assignments
2. **Given** the registry entry for configuration policy templates, **When** nested template setting templates are needed, **Then** the registry can represent that subresource using an approved template (including an item identifier placeholder).
---
### User Story 2 - Graph calls are registry-backed (Priority: P2)
As a maintainer, I need Graph calls for these resources to use the contract registry rather than freeform strings, so that endpoints are governed consistently (naming, review, and drift-check alignment).
**Why this priority**: Prevents silent expansion of Graph surface area and makes review/audit easier.
**Independent Test**: A test can detect reintroduction of hardcoded endpoint substrings in the relevant call sites.
**Acceptance Scenarios**:
1. **Given** code paths that fetch settings catalog definitions or categories, **When** they call Graph, **Then** they source the resource path from the contract registry rather than embedding hardcoded endpoint substrings.
2. **Given** code paths that create/update role assignments, **When** they call Graph, **Then** they source the role-assignments resource path from the contract registry rather than embedding a hardcoded endpoint.
---
### User Story 3 - Regressions are prevented (Priority: P3)
As a maintainer, I need regression tests that fail if these resources become unregistered or if call sites revert to ad-hoc endpoints, so that future refactors cannot silently break governance.
**Why this priority**: The risk is gradual “drift” over time; a regression guard is the lowest-cost long-term control.
**Independent Test**: A test suite can fail on (a) missing registry entries, and (b) hardcoded endpoint usage in targeted files.
**Acceptance Scenarios**:
1. **Given** a future change accidentally removes a required resource from the registry, **When** tests run, **Then** they fail with a clear message identifying which resource is missing.
2. **Given** a future change introduces a hardcoded endpoint string for one of the covered resources, **When** tests run, **Then** they fail and indicate which endpoint substring was reintroduced.
### Edge Cases
- Graph permissions or tenant isolation limitations cause the resource to be inaccessible for a given tenant; contract registration must not imply access.
- The Graph API returns paginated results for list endpoints; registry coverage must not degrade pagination handling.
- The Graph API evolves (resource moved/renamed/deprecated); contract drift check and tests should reveal mismatch quickly.
- A subresource path exists for a registered resource but is not represented in the registry; this must be treated as a governance gap.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature updates the contract registry for Microsoft Graph resources and adds regression tests to ensure completeness and to prevent ad-hoc expansions of Graph calls.
### Functional Requirements
- **FR-001**: The system MUST explicitly register the following Graph resources in the contract registry:
- Configuration Policy Templates
- Configuration Settings (settings catalog definitions)
- Configuration Categories
- RBAC Role Assignments
- **FR-002**: The system MUST represent the “setting templates” subresource under Configuration Policy Templates as part of the registered contract.
- **FR-003**: The system MUST ensure that Graph calls for the above resources are sourced from the contract registry (not embedded as freeform endpoint strings in call sites).
- **FR-003a**: The “no freeform path” enforcement scope MUST be limited to the four specified resources and the known call sites that access them.
- **FR-004**: The system MUST include regression tests that fail when any of the required resources are not registered.
- **FR-005**: The system MUST include regression tests that detect reintroduction of ad-hoc endpoint strings in the targeted call sites for these resources.
- **FR-005a**: The regression guard “targeted call sites” MUST include: ConfigurationPolicyTemplateResolver, SettingsCatalogDefinitionResolver, SettingsCatalogCategoryResolver, RbacOnboardingService, and RbacHealthService.
- **FR-006**: The implementation scope MUST remain limited to the four specified resources; any additional missing resources discovered during implementation MUST be handled via a follow-up spec.
### Registry Identifiers (internal)
To make the requirements testable and to avoid ambiguity, the contract registry MUST contain stable internal identifiers for these four resources (under the registrys “types” section):
| Resource | Registry identifier |
|----------|---------------------|
| Configuration Policy Templates | `configurationPolicyTemplate` |
| Configuration Settings (settings catalog definitions) | `settingsCatalogDefinition` |
| Configuration Categories | `settingsCatalogCategory` |
| RBAC Role Assignments | `rbacRoleAssignment` |
Additionally, the Configuration Policy Template contract MUST include a named subresource for “setting templates” that is templated by the parent template identifier (for example: subresource key `settingTemplates`).
### Assumptions
- Existing authorization and tenant isolation rules already constrain whether any given tenant can access these resources; contract registration does not add or expand permissions.
- The contract drift check enumerates registered top-level resources; nested subresources are still registered for governance consistency and reuse.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of the specified Graph resources are discoverable from the contract registry.
- **SC-002**: Regression tests fail within 1 test run if any specified resource becomes unregistered.
- **SC-003**: Regression tests fail within 1 test run if targeted call sites reintroduce hardcoded endpoint substrings for the specified resources.
- **SC-004**: Maintainers can demonstrate drift-check coverage for these resources without manual inspection (e.g., via automated verification output or test evidence).
- **SC-004a**: Acceptance evidence MUST be satisfied by automated tests (Pest) without requiring a live tenant or manual drift-check output.

View File

@ -0,0 +1,134 @@
# Tasks: Graph Contracts Registry Completeness
**Input**: Design documents from `/specs/095-graph-contracts-registry-completeness/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
**Tests**: Required (Pest). Acceptance evidence is automated tests only.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Ensure the feature workspace and reference docs are in place.
- [X] T001 Confirm spec artifacts are present and up to date in specs/095-graph-contracts-registry-completeness/spec.md and specs/095-graph-contracts-registry-completeness/plan.md
- [X] T002 [P] Review current contract registry structure in config/graph_contracts.php
- [X] T003 [P] Review current contract helper patterns in app/Services/Graph/GraphContractRegistry.php
- [X] T021 If any additional unregistered Graph resources are discovered during review/implementation, record them in specs/095-graph-contracts-registry-completeness/research.md under a “Follow-ups (out of scope)” section and do not expand this specs implementation scope
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish baseline patterns used by subsequent story work.
- [X] T004 Review existing contract coverage test patterns in tests/Feature/Graph/GraphContractRegistryCoverageSpec081Test.php
**Checkpoint**: Foundation ready — user story work can begin.
---
## Phase 3: User Story 1 — Drift coverage is complete (Priority: P1) 🎯 MVP
**Goal**: The contract registry explicitly models the four required resources and the template subresource.
**Independent Test**: A single Pest test file can assert the registry contains the required resources and subresource template.
- [X] T005 [P] [US1] Create a new coverage test file in tests/Feature/Graph/GraphContractRegistryCoverageSpec095Test.php
- [X] T006 [US1] Add contract type entries for the four required resources in config/graph_contracts.php
- [X] T007 [US1] Add a subresource template for “Configuration Policy Template → setting templates” in config/graph_contracts.php
- [X] T008 [US1] Implement registry assertions for the four resources + subresource template in tests/Feature/Graph/GraphContractRegistryCoverageSpec095Test.php
- [X] T009 [US1] Verify the MVP by running the focused test command documented in specs/095-graph-contracts-registry-completeness/quickstart.md
**Checkpoint**: US1 complete when the new coverage test passes and the registry contains the four resources + template subresource.
---
## Phase 4: User Story 2 — Graph calls are registry-backed (Priority: P2)
**Goal**: The five in-scope call sites resolve paths via the contract registry (no hardcoded endpoint substrings for these resources).
**Independent Test**: A regression guard can fail if hardcoded endpoint substrings are reintroduced in the five in-scope files.
- [X] T010 [US2] Add/adjust registry path helpers needed by call sites in app/Services/Graph/GraphContractRegistry.php
- [X] T011 [P] [US2] Refactor Graph template calls to use registry-backed paths in app/Services/Intune/ConfigurationPolicyTemplateResolver.php
- [X] T012 [P] [US2] Refactor settings catalog definition calls to use registry-backed paths in app/Services/Intune/SettingsCatalogDefinitionResolver.php
- [X] T013 [P] [US2] Refactor settings catalog category calls to use registry-backed paths in app/Services/Intune/SettingsCatalogCategoryResolver.php
- [X] T014 [P] [US2] Refactor role assignment create/update/list calls to use registry-backed paths in app/Services/Intune/RbacOnboardingService.php
- [X] T015 [P] [US2] Refactor role assignment health/read calls to use registry-backed paths in app/Services/Intune/RbacHealthService.php
**Checkpoint**: US2 complete when all five call sites use registry-backed paths for the four governed resources.
---
## Phase 5: User Story 3 — Regressions are prevented (Priority: P3)
**Goal**: Automated tests fail if registry entries are removed or if call sites revert to ad-hoc endpoint strings.
**Independent Test**: Pest tests fail within a single run for either (a) missing registry entries or (b) hardcoded endpoints in in-scope files.
- [X] T016 [P] [US3] Add string-regression guard assertions for the five in-scope files in tests/Feature/Graph/GraphContractRegistryCoverageSpec095Test.php
- [X] T017 [US3] Ensure regression guard failure messages identify the missing resource or offending file in tests/Feature/Graph/GraphContractRegistryCoverageSpec095Test.php
- [X] T018 [US3] Run the acceptance tests (focused) documented in specs/095-graph-contracts-registry-completeness/quickstart.md
**Checkpoint**: US3 complete when tests fail on simulated regressions and pass on the final implementation.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T019 [P] Apply formatting to changed files using `vendor/bin/sail bin pint --dirty` (config/graph_contracts.php and app/Services/Graph/GraphContractRegistry.php)
- [X] T020 Run a targeted regression suite that includes the new coverage test and any directly impacted existing tests (tests/Feature/Graph/GraphContractRegistryCoverageSpec095Test.php and tests/Feature/SettingsCatalogDefinitionResolverTest.php)
---
## Dependencies & Execution Order
### Dependency Graph (User Story Order)
```mermaid
graph LR
Setup[Phase 1: Setup] --> Foundational[Phase 2: Foundational]
Foundational --> US1[US1: Registry coverage]
US1 --> US2[US2: Call sites registry-backed]
US2 --> US3[US3: Regression guards]
US3 --> Polish[Phase 6: Polish]
```
### User Story Dependencies
- US1 has no dependencies beyond Foundational.
- US2 depends on US1 (registry entries must exist before call sites can reference them).
- US3 depends on US1 and US2 (guards validate the final state).
---
## Parallel Execution Examples
### User Story 1
- Can be split as:
- T005 (create test skeleton) can proceed in parallel with T006/T007 planning, but assertions should be written before implementation.
### User Story 2
- After T010 is complete, these can run in parallel (different files):
- T011, T012, T013, T014, T015
### User Story 3
- After US2 is complete, T016 can be implemented independently and validated via T018.
---
## Implementation Strategy
### MVP First (US1 only)
1. Complete Phase 12
2. Complete US1 (T005T009)
3. Stop and validate via the focused test
### Incremental Delivery
- US1 → US2 → US3, running acceptance tests after each checkpoint.

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
use App\Services\Graph\GraphContractRegistry;
it('Spec095 keeps graph contract entries for governed deviceManagement resources', function (): void {
$requiredResources = [
'configurationPolicyTemplate' => 'deviceManagement/configurationPolicyTemplates',
'settingsCatalogDefinition' => 'deviceManagement/configurationSettings',
'settingsCatalogCategory' => 'deviceManagement/configurationCategories',
'rbacRoleAssignment' => 'deviceManagement/roleAssignments',
];
foreach ($requiredResources as $contractType => $resourcePath) {
$resource = config("graph_contracts.types.{$contractType}.resource");
expect($resource)
->toBeString("Spec095 missing graph contract resource for {$contractType}")
->toBe($resourcePath, "Spec095 graph contract resource mismatch for {$contractType}");
}
$settingTemplatesPath = config('graph_contracts.types.configurationPolicyTemplate.subresources.settingTemplates.path');
expect($settingTemplatesPath)
->toBeString('Spec095 missing settingTemplates subresource for configurationPolicyTemplate');
expect(str_contains((string) $settingTemplatesPath, '{id}'))
->toBeTrue('Spec095 settingTemplates subresource path must include the parent {id} placeholder');
});
it('Spec095 keeps registry path helpers for governed resources', function (): void {
$contracts = app(GraphContractRegistry::class);
expect($contracts->configurationPolicyTemplateListPath())
->toBe('/deviceManagement/configurationPolicyTemplates');
expect($contracts->configurationPolicyTemplateItemPath('template/abc'))
->toBe('/deviceManagement/configurationPolicyTemplates/template%2Fabc');
expect($contracts->configurationPolicyTemplateSettingTemplatesPath('template/abc'))
->toBe('/deviceManagement/configurationPolicyTemplates/template%2Fabc/settingTemplates');
expect($contracts->settingsCatalogDefinitionItemPath('definition/abc'))
->toBe('/deviceManagement/configurationSettings/definition%2Fabc');
expect($contracts->settingsCatalogCategoryItemPath('category/abc'))
->toBe('/deviceManagement/configurationCategories/category%2Fabc');
expect($contracts->rbacRoleAssignmentItemPath('assignment/abc'))
->toBe('/deviceManagement/roleAssignments/assignment%2Fabc');
});
it('Spec095 guards against hardcoded governed endpoint strings in scoped call sites', function (): void {
$scopedGuards = [
'app/Services/Intune/ConfigurationPolicyTemplateResolver.php' => ['deviceManagement/configurationPolicyTemplates'],
'app/Services/Intune/SettingsCatalogDefinitionResolver.php' => ['deviceManagement/configurationSettings'],
'app/Services/Intune/SettingsCatalogCategoryResolver.php' => ['deviceManagement/configurationCategories'],
'app/Services/Intune/RbacOnboardingService.php' => ['deviceManagement/roleAssignments'],
'app/Services/Intune/RbacHealthService.php' => ['deviceManagement/roleAssignments'],
];
foreach ($scopedGuards as $file => $forbiddenSubstrings) {
$contents = file_get_contents(base_path($file));
expect($contents)
->not->toBeFalse("Spec095 regression guard could not read {$file}");
foreach ($forbiddenSubstrings as $forbiddenSubstring) {
expect(str_contains((string) $contents, $forbiddenSubstring))
->toBeFalse("Spec095 regression guard found hardcoded '{$forbiddenSubstring}' in {$file}; use GraphContractRegistry helper paths instead.");
}
}
});

View File

@ -2,6 +2,7 @@
use App\Models\SettingsCatalogDefinition;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\SettingsCatalogDefinitionResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -31,7 +32,7 @@
// Should NOT call Graph API
$mockClient->shouldNotReceive('request');
$resolver = new SettingsCatalogDefinitionResolver($mockClient);
$resolver = new SettingsCatalogDefinitionResolver($mockClient, new GraphContractRegistry);
// Act
$result = $resolver->resolve([$definitionId]);
@ -56,7 +57,7 @@
->once()
->andReturn($mockResponse);
$resolver = new SettingsCatalogDefinitionResolver($mockClient);
$resolver = new SettingsCatalogDefinitionResolver($mockClient, new GraphContractRegistry);
// Act
$result = $resolver->resolve([$definitionId]);
@ -84,7 +85,7 @@
$mockClient = Mockery::mock(GraphClientInterface::class);
$mockClient->shouldNotReceive('request');
$resolver = new SettingsCatalogDefinitionResolver($mockClient);
$resolver = new SettingsCatalogDefinitionResolver($mockClient, new GraphContractRegistry);
// Act
$result = $resolver->resolveOne($definitionId);
@ -118,7 +119,7 @@
->with('GET', "/deviceManagement/configurationSettings/{$uncachedId}")
->andReturn($mockResponse);
$resolver = new SettingsCatalogDefinitionResolver($mockClient);
$resolver = new SettingsCatalogDefinitionResolver($mockClient, new GraphContractRegistry);
// Act
$result = $resolver->resolve([$cachedId, $uncachedId]);
@ -144,7 +145,7 @@
$mockClient = Mockery::mock(GraphClientInterface::class);
$mockClient->shouldNotReceive('request');
$resolver = new SettingsCatalogDefinitionResolver($mockClient);
$resolver = new SettingsCatalogDefinitionResolver($mockClient, new GraphContractRegistry);
// Act & Assert (should not throw)
expect(fn () => $resolver->warmCache([$definitionId]))->not->toThrow(Exception::class);
@ -164,7 +165,7 @@
->once()
->andThrow(new Exception('Graph API error'));
$resolver = new SettingsCatalogDefinitionResolver($mockClient);
$resolver = new SettingsCatalogDefinitionResolver($mockClient, new GraphContractRegistry);
// Act & Assert (should not throw)
expect(fn () => $resolver->warmCache($definitionIds))->not->toThrow(Exception::class);