diff --git a/docs/open_api_core.yaml b/docs/open_api_core.yaml index 322d50995..c2f5821b4 100644 --- a/docs/open_api_core.yaml +++ b/docs/open_api_core.yaml @@ -4345,6 +4345,43 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorData" + get: + tags: + - User Consent + summary: "/v1/consent/{deployment_id}/admin-consent" + operationId: getApplicationAdminConsentStatus + parameters: + - name: deployment_id + in: path + description: The unique identifier of the deployment. + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/AdminConsentStatus" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorData" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorData" + "500": + description: The server had an error while processing your request. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorData" /v1/conversations/{bucket}/{conversation_path}: get: tags: @@ -13603,6 +13640,21 @@ components: - APPLIED_INVALID - FAILED - SKIPPED + AdminConsentStatus: + type: object + properties: + consented: + type: boolean + grantedAt: + type: integer + grantedBy: + type: string + grantedResources: + type: array + items: + $ref: "#/components/schemas/ConsentResourceEntry" + stale: + type: boolean AdminManifest: type: object properties: diff --git a/server/src/main/java/com/epam/aidial/core/server/AiDial.java b/server/src/main/java/com/epam/aidial/core/server/AiDial.java index afb8fe190..d72b94435 100644 --- a/server/src/main/java/com/epam/aidial/core/server/AiDial.java +++ b/server/src/main/java/com/epam/aidial/core/server/AiDial.java @@ -322,7 +322,7 @@ vertx, settings("config"), null, DeploymentService deploymentService = new DeploymentService(encryptionService, applicationService, accessService, toolSetService, resourceService, applicationSchemaService); - ConsentService consentService = new ConsentService(deploymentService, resourceService); + ConsentService consentService = new ConsentService(deploymentService, resourceService, clock); HealthCheckController healthCheckController = new HealthCheckController(redis, taskExecutor); diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ConsentController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ConsentController.java index a4042adc6..44e3ed40c 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ConsentController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ConsentController.java @@ -9,7 +9,9 @@ import com.epam.aidial.core.server.Proxy; import com.epam.aidial.core.server.ProxyContext; import com.epam.aidial.core.server.data.consent.AcceptConsentRequest; +import com.epam.aidial.core.server.data.consent.AdminConsentStatus; import com.epam.aidial.core.server.data.consent.Consent; +import com.epam.aidial.core.server.data.consent.ConsentGrant; import com.epam.aidial.core.server.data.consent.ReviewConsentResponse; import com.epam.aidial.core.server.log.ResourceDependencyAuditLog; import com.epam.aidial.core.server.service.PermissionDeniedException; @@ -100,6 +102,38 @@ public Future acceptConsent(String deploymentId) { return Future.succeededFuture(); } + @ApiOperation( + method = "GET", + path = "/v1/consent/{deployment_id}/admin-consent", + operationId = "getApplicationAdminConsentStatus", + tags = {"User Consent"}, + parameters = { + @ApiParameter(name = "deployment_id", in = ParameterIn.PATH, required = true, + description = OpenApiDescriptions.DEPLOYMENT_ID) + }, + responses = { + @ApiResponse(code = 200, + description = "Success — consented (live right now: grant exists AND matches the " + + "current declaration, exactly what request-time resolution enforces), " + + "stale, grantedBy, grantedAt, grantedResources (present when a grant exists, " + + "including the stale case)", + body = @ApiSchema(implementation = AdminConsentStatus.class)), + @ApiResponse(code = 403), + @ApiResponse(code = 404), + @ApiResponse(code = 500) + } + ) + public Future getAdminConsentStatus(String deploymentId) { + // Not audited: reads are not consent decisions (no read path in the codebase audits); + // the gate runs before any resolution, so a refusal leaks nothing. + proxy.getTaskExecutor().submit(() -> { + requireAdmin(); + return proxy.getConsentService().describeAdminConsent(context, deploymentId); + }).onSuccess(status -> context.respond(HttpStatus.OK, status)) + .onFailure(error -> handleRequestError(deploymentId, error)); + return Future.succeededFuture(); + } + @ApiOperation( method = "POST", path = "/v1/consent/{deployment_id}/admin-consent", @@ -144,11 +178,11 @@ public Future withdrawAdminConsent(String deploymentId) { } /** - * Both admin-consent operations are the same act with a different verb: admin only (checked + * Both admin-consent mutations are the same act with a different verb: admin only (checked * before any resolution, so a refusal leaks nothing), audited either way — the grant line * carries the approved snapshot, the withdraw line what was withdrawn. */ - private Future adminConsentOperation(String deploymentId, String action, Supplier operation) { + private Future adminConsentOperation(String deploymentId, String action, Supplier operation) { proxy.getTaskExecutor().submit(() -> { requireAdmin(); return operation.get(); @@ -160,8 +194,8 @@ private Future adminConsentOperation(String deploymentId, String action, Supp return Future.succeededFuture(); } - private static List snapshotOf(Consent consent) { - return consent == null ? null : consent.getResources(); + private static List snapshotOf(ConsentGrant grant) { + return grant == null || grant.getConsent() == null ? null : grant.getConsent().getResources(); } private static RuntimeException asRuntime(Throwable error) { diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java b/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java index 3392189f3..bb8367ef8 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java @@ -249,6 +249,13 @@ public class ControllerSelector { default -> null; }; }); + // Registered BEFORE USER_CONSENT for GET, as for POST/DELETE: USER_CONSENT's anchored + // pattern would otherwise swallow "/v1/consent/{id}/admin-consent" whole as a deployment id. + get(RouteTemplate.ADMIN_CONSENT, (proxy, context, pathMatcher) -> { + String deploymentId = UrlUtil.decodePath(pathMatcher.group(1)); + ConsentController controller = new ConsentController(context, proxy); + return () -> controller.getAdminConsentStatus(deploymentId); + }); get(RouteTemplate.USER_CONSENT, (proxy, context, pathMatcher) -> { String deploymentId = UrlUtil.decodePath(pathMatcher.group(1)); ConsentController controller = new ConsentController(context, proxy); diff --git a/server/src/main/java/com/epam/aidial/core/server/data/consent/AdminConsentStatus.java b/server/src/main/java/com/epam/aidial/core/server/data/consent/AdminConsentStatus.java new file mode 100644 index 000000000..64af98a73 --- /dev/null +++ b/server/src/main/java/com/epam/aidial/core/server/data/consent/AdminConsentStatus.java @@ -0,0 +1,32 @@ +package com.epam.aidial.core.server.data.consent; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.List; + +/** + * The admin-consent status read (GET /v1/consent/{id}/admin-consent). {@code consented} means + * live right now — a record exists AND its snapshot equals the current declaration — exactly + * what the request-time gate enforces; a stale record never reports consented. Provenance and + * the approved snapshot are present whenever a record exists, including the stale case ("last + * approved by X at T — re-approve"). The current declaration is deliberately not duplicated + * here — it lives in the app definition and the user-consent document. + */ +@Data +@Accessors(chain = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AdminConsentStatus { + + private boolean consented; + + /** True when a record exists but no longer matches the current declaration. */ + private Boolean stale; + + private String grantedBy; + + private Long grantedAt; + + private List grantedResources; +} diff --git a/server/src/main/java/com/epam/aidial/core/server/data/consent/ConsentGrant.java b/server/src/main/java/com/epam/aidial/core/server/data/consent/ConsentGrant.java new file mode 100644 index 000000000..f08b23050 --- /dev/null +++ b/server/src/main/java/com/epam/aidial/core/server/data/consent/ConsentGrant.java @@ -0,0 +1,30 @@ +package com.epam.aidial.core.server.data.consent; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * The stored admin-consent record: the approved {@link Consent} snapshot plus its provenance — + * who granted it and when, server-stamped at grant time. The envelope lives on the admin record + * only; the user record stays bare (its who/when are structural: the record's bucket is the + * consenting user, the blob metadata is the when). Because the provenance fields sit outside the + * echoed {@link Consent} document, nothing on the client round-trip path is stampable, and the + * content-binding compare (on {@link #consent}) is untouched by them. + * + *

{@code ignoreUnknown}: records written by the pre-envelope commits of this branch store a + * bare {@code Consent} body at the same key — lenient reading turns those into {@code consent == + * null}, which flows into the already-built fail-closed path (stale, nothing resolves); without + * it, the strict mapper throws and even withdraw cannot delete the record. + */ +@Data +@Accessors(chain = true) +@JsonIgnoreProperties(ignoreUnknown = true) +public class ConsentGrant { + + private Consent consent; + + private String grantedBy; + + private Long grantedAt; +} diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ConsentService.java b/server/src/main/java/com/epam/aidial/core/server/service/ConsentService.java index 0b320a2cc..8a960335f 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/ConsentService.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/ConsentService.java @@ -4,7 +4,9 @@ import com.epam.aidial.core.config.Deployment; import com.epam.aidial.core.config.ResourceDependency; import com.epam.aidial.core.server.ProxyContext; +import com.epam.aidial.core.server.data.consent.AdminConsentStatus; import com.epam.aidial.core.server.data.consent.Consent; +import com.epam.aidial.core.server.data.consent.ConsentGrant; import com.epam.aidial.core.server.data.consent.ReviewConsentResponse; import com.epam.aidial.core.server.util.BucketBuilder; import com.epam.aidial.core.server.util.ProxyUtil; @@ -23,6 +25,7 @@ import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.function.LongSupplier; import static com.epam.aidial.core.storage.http.HttpStatus.BAD_REQUEST; @@ -35,9 +38,12 @@ public class ConsentService { private final ResourceService resourceService; - public ConsentService(DeploymentService deploymentService, ResourceService resourceService) { + private final LongSupplier clock; + + public ConsentService(DeploymentService deploymentService, ResourceService resourceService, LongSupplier clock) { this.deploymentService = deploymentService; this.resourceService = resourceService; + this.clock = clock; } public ReviewConsentResponse buildConsent(ProxyContext context, String deploymentId) { @@ -117,38 +123,83 @@ public void verifyUserConsent(ProxyContext context, Deployment deployment) { /** * The v1 gate: an administrator approves the application's declared resource dependencies. - * The stored record is the approved snapshot — only the consent endpoint reaches this type - * (ADMIN_CONSENT is unmapped in ResourceTypes.of()), and any declaration change re-requires - * the grant because {@link #isAdminConsented} compares snapshots. The record is keyed by the + * The stored record is the {@link ConsentGrant} envelope — the approved snapshot plus + * server-stamped provenance (who granted, when) — and only the consent endpoint reaches this + * type (ADMIN_CONSENT is unmapped in ResourceTypes.of()). Any declaration change re-requires + * the grant because the status check compares snapshots. The record is keyed by the * RESOLVED application's canonical name — the same identity the resolver reads — never by the * raw request id, so the two sides cannot diverge for names that carry percent-sequences. */ - public Consent grantAdminConsent(ProxyContext context, String deploymentId) { + public ConsentGrant grantAdminConsent(ProxyContext context, String deploymentId) { Application application = requireDeclaringApplication(context, deploymentId); - Consent approval = adminConsentOf(application.getResourceDependencies()); + ConsentGrant grant = new ConsentGrant() + .setConsent(adminConsentOf(application.getResourceDependencies())) + .setGrantedBy(context.getUserId()) + .setGrantedAt(clock.getAsLong()); resourceService.putResource(getAdminConsentDescription(application.getName()), - ProxyUtil.convertToString(approval), EtagHeader.ANY); - return approval; + ProxyUtil.convertToString(grant), EtagHeader.ANY); + return grant; } /** * Withdraws the approval — the application stops resolving dependencies for every user - * immediately. Returns the withdrawn record (null when absent) so the audit event can carry + * immediately. Returns the withdrawn grant (null when absent) so the audit event can carry * exactly what was withdrawn. Resolves the application first, for the same key-identity * reason as the grant. */ - public Consent withdrawAdminConsent(ProxyContext context, String deploymentId) { + public ConsentGrant withdrawAdminConsent(ProxyContext context, String deploymentId) { Application application = requireApplication(context, deploymentId); ResourceDescriptor descriptor = getAdminConsentDescription(application.getName()); - Consent withdrawn = readAdminConsent(descriptor); + ConsentGrant withdrawn = readAdminConsent(descriptor); resourceService.deleteResource(descriptor, EtagHeader.ANY); return withdrawn; } - /** Content-bound check: the stored snapshot must deep-equal the declaration's current snapshot. */ + /** + * The admin-consent status read: everything the panel needs in one call. {@code consented} + * means live right now — a record exists and its snapshot equals the current declaration — + * exactly what {@link #isAdminConsented} (and thus the request-time gate) enforces. Provenance + * and the approved snapshot are present whenever a record exists, including the stale case. + */ + public AdminConsentStatus describeAdminConsent(ProxyContext context, String deploymentId) { + Application application = requireApplication(context, deploymentId); + return describeAdminConsent(application.getName(), application.getResourceDependencies()); + } + + /** Shared by the status read and the request-time gate — the two cannot drift. */ + public AdminConsentStatus describeAdminConsent(String applicationId, List declaration) { + ConsentGrant stored = readAdminConsent(getAdminConsentDescription(applicationId)); + if (stored == null) { + return new AdminConsentStatus().setConsented(false); + } + List grantedResources = + stored.getConsent() == null ? List.of() : stored.getConsent().getResources(); + // The shared predicate: the same comparison the request-time gate runs, so the status + // read reports exactly what the gate enforces. + boolean matches = matches(stored, declaration); + return new AdminConsentStatus() + .setConsented(matches) + .setStale(!matches) + .setGrantedBy(stored.getGrantedBy()) + .setGrantedAt(stored.getGrantedAt()) + .setGrantedResources(grantedResources); + } + + /** + * Content-bound check: the stored snapshot must deep-equal the declaration's current snapshot. + * Zero-allocation on the request-time hot path — no presentation DTO is built, and the gate + * never depends on fields the status read adds. + */ public boolean isAdminConsented(String applicationId, List declaration) { - Consent stored = readAdminConsent(getAdminConsentDescription(applicationId)); - return stored != null && Objects.equals(stored.getResources(), resourceEntriesOf(declaration)); + return matches(readAdminConsent(getAdminConsentDescription(applicationId)), declaration); + } + + /** The one predicate both the gate and the status read enforce — provenance never participates. */ + private static boolean matches(ConsentGrant stored, List declaration) { + // Fail closed on a consent-less envelope (a legacy bare-Consent body reads as consent==null): + // nothing matches, so the record reads as stale rather than consented. + return stored != null && stored.getConsent() != null + && Objects.equals(stored.getConsent().getResources(), resourceEntriesOf(declaration)); } private Application requireDeclaringApplication(ProxyContext context, String deploymentId) { @@ -206,9 +257,9 @@ private static ResourceDescriptor getAdminConsentDescription(String deploymentId ResourceDescriptor.PUBLIC_BUCKET, ResourceDescriptor.PUBLIC_LOCATION, deploymentId); } - private Consent readAdminConsent(ResourceDescriptor descriptor) { - String consent = resourceService.getResource(descriptor); - return ProxyUtil.convertToObject(consent, Consent.class); + private ConsentGrant readAdminConsent(ResourceDescriptor descriptor) { + String grant = resourceService.getResource(descriptor); + return ProxyUtil.convertToObject(grant, ConsentGrant.class); } private String getRootDeploymentId(ProxyContext context, Deployment current) { diff --git a/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyConsentApiTest.java b/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyConsentApiTest.java index 636c0ad0c..5d03f5ce3 100644 --- a/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyConsentApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyConsentApiTest.java @@ -77,6 +77,78 @@ void testOnlyAdministratorsMayConsent() { void testUnknownDeploymentIsNotFound() { verify(send(HttpMethod.POST, "/v1/consent/unknown-app/admin-consent", null, "", "authorization", "admin"), 404); + verify(send(HttpMethod.GET, "/v1/consent/unknown-app/admin-consent", null, "", + "authorization", "admin"), 404); + } + + @Test + void testAdminConsentStatusReadsTheTriState() { + // Never granted: consented false, nothing else — no stale, no provenance. + verify(send(HttpMethod.PUT, "/v1/applications/public/dependency-consent-app", null, + DECLARING_APP_BODY, "authorization", "admin", "If-None-Match", "*"), 200); + Response neverGranted = send(HttpMethod.GET, "/v1/consent/" + DECLARING_APP + "/admin-consent", null, "", + "authorization", "admin"); + verify(neverGranted, 200); + assertEquals("{\"consented\":false}", neverGranted.body(), () -> "Body: " + neverGranted.body()); + + // Granted: consented true, stale false, provenance and the approved snapshot present. + verify(send(HttpMethod.POST, "/v1/consent/" + DECLARING_APP + "/admin-consent", null, "", + "authorization", "admin"), 200); + Response granted = send(HttpMethod.GET, "/v1/consent/" + DECLARING_APP + "/admin-consent", null, "", + "authorization", "admin"); + verify(granted, 200); + assertTrue(granted.body().contains("\"consented\":true"), () -> "Body: " + granted.body()); + assertTrue(granted.body().contains("\"stale\":false"), () -> "Body: " + granted.body()); + assertTrue(granted.body().contains("\"grantedBy\":\"admin\""), () -> "Body: " + granted.body()); + assertTrue(granted.body().contains("\"grantedAt\":"), () -> "Body: " + granted.body()); + assertTrue(granted.body().contains("\"url\":\"current-user/skills/\""), () -> "Body: " + granted.body()); + + // Declaration changed since the grant: not consented, stale — and the last approval stays + // visible for the panel's re-approve view. + verify(send(HttpMethod.PUT, "/v1/applications/public/dependency-consent-app", null, """ + { + "endpoint": "http://application1/v1/completions", + "display_name": "Dependency Consent App", + "resource_dependencies": [ + {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "current-user/skills/"}, "access": ["write"], "required": true}, + {"kind": "dial.resourceLink", "link_id": "lnk_extra", "target": {"path": "current-user/files/dep-extra/"}, "access": ["read"]} + ] + } + """, "authorization", "admin"), 200); + Response stale = send(HttpMethod.GET, "/v1/consent/" + DECLARING_APP + "/admin-consent", null, "", + "authorization", "admin"); + verify(stale, 200); + assertTrue(stale.body().contains("\"consented\":false"), () -> "Body: " + stale.body()); + assertTrue(stale.body().contains("\"stale\":true"), () -> "Body: " + stale.body()); + assertTrue(stale.body().contains("\"grantedBy\":\"admin\""), "the last approval's provenance survives the stale transition"); + assertTrue(stale.body().contains("\"url\":\"current-user/skills/\""), "the approved snapshot survives for the re-approve diff view"); + + // Withdrawn: back to the clean never-granted shape. + verify(send(HttpMethod.DELETE, "/v1/consent/" + DECLARING_APP + "/admin-consent", null, "", + "authorization", "admin"), 200); + Response withdrawn = send(HttpMethod.GET, "/v1/consent/" + DECLARING_APP + "/admin-consent", null, "", + "authorization", "admin"); + verify(withdrawn, 200); + assertEquals("{\"consented\":false}", withdrawn.body(), () -> "Body: " + withdrawn.body()); + } + + @Test + void testAdminConsentStatusForNonDeclarersAndNonAdmins() { + // A non-declaring app is a legitimate "nothing consented" answer, not an error. + verify(send(HttpMethod.PUT, "/v1/applications/public/plain-consent-app", null, """ + { + "endpoint": "http://application1/v1/completions", + "display_name": "Plain App" + } + """, "authorization", "admin", "If-None-Match", "*"), 200); + Response status = send(HttpMethod.GET, "/v1/consent/applications/public/plain-consent-app/admin-consent", + null, "", "authorization", "admin"); + verify(status, 200); + assertEquals("{\"consented\":false}", status.body(), () -> "Body: " + status.body()); + + // The status read is gated exactly like the writers — fail-closed for non-admins. + verify(send(HttpMethod.GET, "/v1/consent/applications/public/plain-consent-app/admin-consent", + null, ""), 403); } /** diff --git a/server/src/test/java/com/epam/aidial/core/server/service/ConsentServiceTest.java b/server/src/test/java/com/epam/aidial/core/server/service/ConsentServiceTest.java index b20f1c0fa..6ec09dc1d 100644 --- a/server/src/test/java/com/epam/aidial/core/server/service/ConsentServiceTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/service/ConsentServiceTest.java @@ -7,7 +7,9 @@ import com.epam.aidial.core.config.ResourceDependency; import com.epam.aidial.core.server.ProxyContext; import com.epam.aidial.core.server.data.ApiKeyData; +import com.epam.aidial.core.server.data.consent.AdminConsentStatus; import com.epam.aidial.core.server.data.consent.Consent; +import com.epam.aidial.core.server.data.consent.ConsentGrant; import com.epam.aidial.core.server.data.consent.ReviewConsentResponse; import com.epam.aidial.core.server.util.ProxyUtil; import com.epam.aidial.core.storage.http.HttpException; @@ -33,11 +35,13 @@ import java.util.List; import java.util.Set; import java.util.TreeMap; +import java.util.function.LongSupplier; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -70,6 +74,9 @@ public ObjectNode objectNode() { @Mock ProxyContext context; + @Mock + private LongSupplier clock; + @InjectMocks private ConsentService service; @@ -607,17 +614,25 @@ public void testBuildConsent_IncludesDeclaredResourcesRegardlessOfConsentRequire } @Test - public void testGrantAdminConsent_StoresTheSnapshotInPublicAdminConsentRecord() { + public void testGrantAdminConsent_StoresTheEnvelopeWithProvenanceInPublicAdminConsentRecord() { when(deploymentService.findDeployment(eq(context), eq("app"))).thenReturn(declaringApplication()); + when(context.getUserId()).thenReturn("admin-sub"); + when(clock.getAsLong()).thenReturn(1788394665564L); - Consent approval = service.grantAdminConsent(context, "app"); + ConsentGrant grant = service.grantAdminConsent(context, "app"); - assertEquals(List.of(resourceEntry("current-user/skills/")), approval.getResources()); + assertEquals(List.of(resourceEntry("current-user/skills/")), grant.getConsent().getResources()); + assertEquals("admin-sub", grant.getGrantedBy(), "provenance is server-stamped from the authenticated admin"); + assertEquals(1788394665564L, grant.getGrantedAt()); ArgumentCaptor captor = ArgumentCaptor.forClass(ResourceDescriptor.class); - verify(resourceService).putResource(captor.capture(), anyString(), eq(EtagHeader.ANY)); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + verify(resourceService).putResource(captor.capture(), bodyCaptor.capture(), eq(EtagHeader.ANY)); ResourceDescriptor descriptor = captor.getValue(); assertEquals(ResourceTypes.ADMIN_CONSENT, descriptor.getType()); assertTrue(descriptor.isPublic(), "the admin's yes is one record per app, always in the public bucket"); + // The stored body round-trips as the envelope — the snapshot sits inside it, stamps survive. + ConsentGrant stored = ProxyUtil.convertToObject(bodyCaptor.getValue(), ConsentGrant.class); + assertEquals(grant, stored); } @Test @@ -631,13 +646,21 @@ public void testGrantAdminConsent_RejectsApplicationWithoutDeclaration() { @Test public void testIsAdminConsented_IsContentBoundToTheDeclaration() { when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(""" - {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]} + {"consent": {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]}, + "grantedBy": "admin-sub", "grantedAt": 1788394665564} """); assertTrue(service.isAdminConsented("app", declaration("current-user/skills/"))); // any declaration change re-requires the grant — an extra entry, a reordered section assertFalse(service.isAdminConsented("app", declaration("current-user/skills/", "files/public/p/"))); assertFalse(service.isAdminConsented("app", declaration("files/public/p/", "current-user/skills/"))); + // the grant's own provenance never participates in the binding — the same snapshot under a + // different who/when still stands + when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(""" + {"consent": {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]}, + "grantedBy": "another-admin", "grantedAt": 9999999999999} + """); + assertTrue(service.isAdminConsented("app", declaration("current-user/skills/"))); } @Test @@ -648,15 +671,96 @@ public void testIsAdminConsented_WhenNoRecordWasEverGranted() { } @Test - public void testWithdrawAdminConsent_ReturnsTheWithdrawnRecordForTheAudit() { - when(deploymentService.findDeployment(eq(context), eq("app"))).thenReturn(declaringApplication()); + public void testDescribeAdminConsent_NeverGranted() { + when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(null); + + AdminConsentStatus status = service.describeAdminConsent("app", declaration("current-user/skills/")); + + assertFalse(status.isConsented()); + assertNull(status.getStale(), "stale is meaningless without a record"); + assertNull(status.getGrantedBy()); + assertNull(status.getGrantedAt()); + assertNull(status.getGrantedResources()); + } + + @Test + public void testDescribeAdminConsent_LiveGrant() { + when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(""" + {"consent": {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]}, + "grantedBy": "admin-sub", "grantedAt": 1788394665564} + """); + + AdminConsentStatus status = service.describeAdminConsent("app", declaration("current-user/skills/")); + + assertTrue(status.isConsented(), "consented means live right now — exactly what the resolver enforces"); + assertFalse(status.getStale()); + assertEquals("admin-sub", status.getGrantedBy()); + assertEquals(1788394665564L, status.getGrantedAt()); + assertEquals(List.of(resourceEntry("current-user/skills/")), status.getGrantedResources()); + } + + @Test + public void testDescribeAdminConsent_StaleGrantKeepsTheLastApproval() { + when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(""" + {"consent": {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]}, + "grantedBy": "admin-sub", "grantedAt": 1788394665564} + """); + + // the declaration changed since the grant — nothing resolves at runtime, and the status + // must not report consented; the last approval stays visible for the panel's re-approve view + AdminConsentStatus status = service.describeAdminConsent("app", declaration("files/public/p/")); + + assertFalse(status.isConsented(), "a stale record never reports consented — the runtime resolves nothing"); + assertTrue(status.getStale()); + assertEquals("admin-sub", status.getGrantedBy()); + assertEquals(1788394665564L, status.getGrantedAt()); + assertEquals(List.of(resourceEntry("current-user/skills/")), status.getGrantedResources()); + } + + @Test + public void testDescribeAdminConsent_ResolvesTheApplicationAndReportsNonDeclarers() { + // A non-declaring app is a legitimate "nothing consented" answer, not an error — reads inform. + Application plain = new Application(); + plain.setName("app"); + when(deploymentService.findDeployment(eq(context), eq("app"))).thenReturn(plain); + when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(null); + + AdminConsentStatus status = service.describeAdminConsent(context, "app"); + + assertFalse(status.isConsented()); + assertNull(status.getStale()); + } + + @Test + public void testDescribeAdminConsent_LegacyBareConsentRecordFailsClosedWithoutThrowing() { + // Records written by the pre-envelope commits of this branch store a bare Consent body at + // the same key. Lenient reading must turn those into consent==null -> the fail-closed + // stale path — never a throw, which would break the resolver (400 on every user call) and + // make the record un-withdrawable. when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(""" {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]} """); - Consent withdrawn = service.withdrawAdminConsent(context, "app"); + AdminConsentStatus status = service.describeAdminConsent("app", declaration("current-user/skills/")); + + assertFalse(status.isConsented(), "a legacy record never reads as consented — fail closed"); + assertTrue(status.getStale()); + assertEquals(List.of(), status.getGrantedResources()); + assertFalse(service.isAdminConsented("app", declaration("current-user/skills/"))); + } + + @Test + public void testWithdrawAdminConsent_ReturnsTheWithdrawnGrantForTheAudit() { + when(deploymentService.findDeployment(eq(context), eq("app"))).thenReturn(declaringApplication()); + when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(""" + {"consent": {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]}, + "grantedBy": "admin-sub", "grantedAt": 1788394665564} + """); + + ConsentGrant withdrawn = service.withdrawAdminConsent(context, "app"); - assertEquals(List.of(resourceEntry("current-user/skills/")), withdrawn.getResources()); + assertEquals(List.of(resourceEntry("current-user/skills/")), withdrawn.getConsent().getResources()); + assertEquals("admin-sub", withdrawn.getGrantedBy()); verify(resourceService).deleteResource(any(ResourceDescriptor.class), eq(EtagHeader.ANY)); } @@ -669,6 +773,8 @@ public void testAdminConsentRecordIsKeyedByTheResolvedApplicationsCanonicalName( application.setName("applications/public/gpt-helpe%2572"); when(deploymentService.findDeployment(eq(context), eq("applications/public/gpt-helpe%2572"))) .thenReturn(application); + when(context.getUserId()).thenReturn("admin-sub"); + when(clock.getAsLong()).thenReturn(1L); service.grantAdminConsent(context, "applications/public/gpt-helpe%2572");