Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/open_api_core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<Consent> operation) {
private Future<?> adminConsentOperation(String deploymentId, String action, Supplier<ConsentGrant> operation) {
proxy.getTaskExecutor().submit(() -> {
requireAdmin();
return operation.get();
Expand All @@ -160,8 +194,8 @@ private Future<?> adminConsentOperation(String deploymentId, String action, Supp
return Future.succeededFuture();
}

private static List<Consent.ResourceEntry> snapshotOf(Consent consent) {
return consent == null ? null : consent.getResources();
private static List<Consent.ResourceEntry> snapshotOf(ConsentGrant grant) {
return grant == null || grant.getConsent() == null ? null : grant.getConsent().getResources();
}

private static RuntimeException asRuntime(Throwable error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Consent.ResourceEntry> grantedResources;
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>{@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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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) {
Expand Down Expand Up @@ -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<ResourceDependency> declaration) {
ConsentGrant stored = readAdminConsent(getAdminConsentDescription(applicationId));
if (stored == null) {
return new AdminConsentStatus().setConsented(false);
}
List<Consent.ResourceEntry> 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<ResourceDependency> 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<ResourceDependency> 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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading