Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ public ResourceCredentials getRefreshedResourceCredentials(CredentialsLocator cr
* Resolves the owner's USER-level credential only, with auto-refresh — and <b>no fallback</b> to
* GLOBAL/APPLICATION levels. Used by the on-behalf-of (OBO) retrieval path, which must fail closed
* rather than silently serve a shared/app-level identity. Returns {@code null} when the owner has no
* USER-level credential for the scope (the locator must carry only a USER bucket, but the userSub
* USER-level credential for the scope (the locator must carry only a USER bucket, but the owner user-id
* match is enforced defensively here too).
*
* <p>When a credential exists but the owner did not grant offline-usage consent, it is returned <b>as
Expand All @@ -239,7 +239,7 @@ public ResourceCredentials getRefreshedResourceCredentials(CredentialsLocator cr
@Nullable
public ResourceCredentials getRefreshedUserCredentials(CredentialsLocator credentialsLocator,
ResourceAuthSettings authSettings,
String ownerSub) {
String ownerUserId) {
if (authSettings.getAuthenticationType() == AuthenticationType.NONE) {
return null;
}
Expand All @@ -252,7 +252,7 @@ public ResourceCredentials getRefreshedUserCredentials(CredentialsLocator creden
ResourceCredentials stored = getResourceCredentials(userDescriptor);
if (stored == null
|| !stored.getCredentialsLevel().equals(CredentialsLevel.USER)
|| !Objects.equals(ownerSub, stored.getUserId())) {
|| !Objects.equals(ownerUserId, stored.getUserId())) {
return null;
}
// Gate consent BEFORE refreshing: an OBO probe against a non-consented credential must not rotate the
Expand All @@ -267,7 +267,7 @@ public ResourceCredentials getRefreshedUserCredentials(CredentialsLocator creden
// window — accepted, since revocation via sign-out normally deletes the credential outright.)
if (userCredentials != null
&& userCredentials.getCredentialsLevel().equals(CredentialsLevel.USER)
&& Objects.equals(ownerSub, userCredentials.getUserId())
&& Objects.equals(ownerUserId, userCredentials.getUserId())
&& userCredentials.isOfflineUsageConsent()) {
return userCredentials;
}
Expand Down
2 changes: 1 addition & 1 deletion docs/open_api_core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13192,7 +13192,7 @@ components:
OboCredentialsRequest:
type: object
properties:
ownerSub:
ownerUserId:
type: string
url:
type: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,12 +288,12 @@ public Future<?> getOboCredentials() {
}

ExternalService externalService = resolveExternalServiceDefinition(
app.application, scope[0], scope[1], request.getOwnerSub());
app.application, scope[0], scope[1], request.getOwnerUserId());
ResourceAuthSettings authSettings = externalService.getAuthSettings();
CredentialsLocator locator = CredentialsLocatorFactory.fromExternalServiceScopeForOwner(
request.getUrl(), request.getOwnerSub(), context);
request.getUrl(), request.getOwnerUserId(), context);
ResourceCredentials credentials = resourceCredentialsService.getRefreshedUserCredentials(
locator, authSettings, request.getOwnerSub());
locator, authSettings, request.getOwnerUserId());

// Fail closed: no fallback to APP/GLOBAL. Missing owner credential ⇒ 404.
if (credentials == null) {
Expand All @@ -304,12 +304,12 @@ public Future<?> getOboCredentials() {
}

ExternalServiceCredentialsResponse response = toCredentialsResponse(credentials, request.getUrl());
ExternalServiceAuditLog.oboRetrieval(context, scope[0], scope[1], request.getOwnerSub(), null);
ExternalServiceAuditLog.oboRetrieval(context, scope[0], scope[1], request.getOwnerUserId(), null);
return response;
} catch (RuntimeException e) {
// Audit failures too — including a malformed url that fails scope parsing (best-effort ids).
String[] scope = safeParseScope(request.getUrl());
ExternalServiceAuditLog.oboRetrieval(context, scope[0], scope[1], request.getOwnerSub(), e);
ExternalServiceAuditLog.oboRetrieval(context, scope[0], scope[1], request.getOwnerUserId(), e);
throw e;
}
}))
Expand Down Expand Up @@ -353,13 +353,13 @@ private ResolvedExternalService resolveExternalService(String scopeId) {
/**
* Canonical external-service definition resolver. Resolves the admin/inline definition first; when the
* app permits it ({@code allow_user_external_services}) and no inline definition exists, falls back to a
* user-authored definition in the owner's bucket. {@code ownerSub} is the credential
* user-authored definition in the owner's bucket. {@code ownerUserId} is the credential
* owner on the OBO path; {@code null} on caller-scoped paths, where the caller is the owner.
*/
private ResolvedExternalService resolveExternalService(String scopeId, @Nullable String ownerSub) {
private ResolvedExternalService resolveExternalService(String scopeId, @Nullable String ownerUserId) {
String[] parts = CredentialsLocatorFactory.parseExternalServiceScope(scopeId);
ResolvedApplication app = resolveApplication(parts[0]);
ExternalService externalService = resolveExternalServiceDefinition(app.application, parts[0], parts[1], ownerSub);
ExternalService externalService = resolveExternalServiceDefinition(app.application, parts[0], parts[1], ownerUserId);
return new ResolvedExternalService(app.application, externalService, app.applicationDescriptor, app.staticApp);
}

Expand All @@ -384,11 +384,11 @@ private ResolvedApplication resolveApplication(String appPart) {
}

private ExternalService resolveExternalServiceDefinition(Application application, String appPart,
String externalServiceId, @Nullable String ownerSub) {
String externalServiceId, @Nullable String ownerUserId) {
ExternalService externalService = application.getExternalServices() == null
? null : application.getExternalServices().get(externalServiceId);
if (externalService == null && application.isAllowUserExternalServices()) {
String owner = ownerSub != null ? ownerSub : context.getUserId();
String owner = ownerUserId != null ? ownerUserId : context.getUserId();
if (owner != null) {
externalService = userExternalServiceService.get(owner, appPart, externalServiceId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public class OboCredentialsRequest {
@NotBlank(message = "url should be specified")
private String url;

@NotBlank(message = "owner_sub should be specified")
@JsonAlias({"ownerSub", "owner_sub"})
private String ownerSub;
@NotBlank(message = "owner_user_id should be specified")
@JsonAlias({"ownerUserId", "owner_user_id"})
private String ownerUserId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ private ExternalServiceAuditLog() {

/** One event per OBO retrieval outcome; {@code error} is {@code null} on success. */
public static void oboRetrieval(ProxyContext context, String applicationId, String externalServiceId,
String ownerSub, RuntimeException error) {
String ownerUserId, RuntimeException error) {
String outcome = switch (error) {
case null -> "SUCCESS";
case ConsentRequiredException ignored -> "CONSENT_REQUIRED";
Expand All @@ -41,9 +41,9 @@ public static void oboRetrieval(ProxyContext context, String applicationId, Stri
default -> "ERROR";
};
// reason echoes only the exception message, never a response body or secret — keep it that way.
AUDIT.info("event=obo_credential_retrieval outcome={} actor={} owner_sub={} application_id={} "
AUDIT.info("event=obo_credential_retrieval outcome={} actor={} owner_user_id={} application_id={} "
+ "external_service_id={} trace_id={}{}",
outcome, actorEvidence(context), sanitizeToken(ownerSub), sanitizeToken(applicationId),
outcome, actorEvidence(context), sanitizeToken(ownerUserId), sanitizeToken(applicationId),
sanitizeToken(externalServiceId), context.getTraceId(),
error == null ? "" : " reason=\"%s\"".formatted(sanitizeReason(error.getMessage())));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

/**
* User-authored external-service definitions: each is its own resource in the author's
* bucket at {@code Users/{ownerSub}/external_services/applications/{app_id}/{id}}. Isolation rides on
* bucket at {@code Users/{ownerUserId}/external_services/applications/{app_id}/{id}}. Isolation rides on
* bucket ownership; the {@code client_secret} is encrypted under the owner's CEK.
*/
@RequiredArgsConstructor
Expand All @@ -42,27 +42,27 @@ public class UserExternalServiceService {
private final ResourceAuthSettingsEncryptionService secretEncryptionService;
private final EncryptionService bucketEncryptionService;

public ResourceDescriptor descriptor(String ownerSub, String appPart, String serviceId) {
requireOwner(ownerSub);
public ResourceDescriptor descriptor(String ownerUserId, String appPart, String serviceId) {
requireOwner(ownerUserId);
ExternalServiceValidation.validateServiceId(serviceId);
String bucketLocation = BucketBuilder.USER_BUCKET_PATTERN.formatted(ownerSub);
String bucketLocation = BucketBuilder.USER_BUCKET_PATTERN.formatted(ownerUserId);
String bucketName = bucketEncryptionService.encrypt(bucketLocation);
List<String> parentFolders = applicationSegments(appPart);
return new ResourceDescriptor(ResourceTypes.EXTERNAL_SERVICE, serviceId, parentFolders, bucketName, bucketLocation, false);
}

private ResourceDescriptor folderDescriptor(String ownerSub, String appPart) {
requireOwner(ownerSub);
String bucketLocation = BucketBuilder.USER_BUCKET_PATTERN.formatted(ownerSub);
private ResourceDescriptor folderDescriptor(String ownerUserId, String appPart) {
requireOwner(ownerUserId);
String bucketLocation = BucketBuilder.USER_BUCKET_PATTERN.formatted(ownerUserId);
String bucketName = bucketEncryptionService.encrypt(bucketLocation);
List<String> segments = applicationSegments(appPart);
String name = segments.remove(segments.size() - 1);
return new ResourceDescriptor(ResourceTypes.EXTERNAL_SERVICE, name, segments, bucketName, bucketLocation, true);
}

// A blank owner would derive the shared "Users/null/" bucket (cross-caller secret leak); require a real owner.
private static void requireOwner(String ownerSub) {
if (StringUtils.isBlank(ownerSub)) {
private static void requireOwner(String ownerUserId) {
if (StringUtils.isBlank(ownerUserId)) {
throw new PermissionDeniedException("A user identity is required to manage user-authored external services");
}
}
Expand All @@ -75,8 +75,8 @@ private static List<String> applicationSegments(String appPart) {
return segments;
}

public ExternalService put(String ownerSub, String appPart, String serviceId, ExternalService service, String author) {
ResourceDescriptor resource = descriptor(ownerSub, appPart, serviceId);
public ExternalService put(String ownerUserId, String appPart, String serviceId, ExternalService service, String author) {
ResourceDescriptor resource = descriptor(ownerUserId, appPart, serviceId);
BucketInfo bucket = new BucketInfo(resource.getBucketName(), resource.getBucketLocation());
String aad = resource.getAbsoluteFilePath();
MutableObject<ExternalService> result = new MutableObject<>();
Expand All @@ -94,8 +94,8 @@ public ExternalService put(String ownerSub, String appPart, String serviceId, Ex
}

/** Reads and decrypts the user-authored definition, or {@code null} if the owner has none for this scope. */
public ExternalService get(String ownerSub, String appPart, String serviceId) {
ResourceDescriptor resource = descriptor(ownerSub, appPart, serviceId);
public ExternalService get(String ownerUserId, String appPart, String serviceId) {
ResourceDescriptor resource = descriptor(ownerUserId, appPart, serviceId);
Pair<ResourceItemMetadata, String> stored = resourceService.getResourceWithMetadata(resource, EtagHeader.ANY);
if (stored == null || stored.getValue() == null) {
return null;
Expand All @@ -106,8 +106,8 @@ public ExternalService get(String ownerSub, String appPart, String serviceId) {
}

/** Lists and decrypts all user-authored definitions the owner has for this application (id → definition). */
public Map<String, ExternalService> list(String ownerSub, String appPart) {
ResourceDescriptor folder = folderDescriptor(ownerSub, appPart);
public Map<String, ExternalService> list(String ownerUserId, String appPart) {
ResourceDescriptor folder = folderDescriptor(ownerUserId, appPart);
Map<String, ExternalService> result = new LinkedHashMap<>();
String token = null;
do {
Expand All @@ -119,7 +119,7 @@ public Map<String, ExternalService> list(String ownerSub, String appPart) {
if (items != null) {
for (MetadataBase item : items) {
if (item.getNodeType() == NodeType.ITEM) {
ExternalService service = get(ownerSub, appPart, item.getName());
ExternalService service = get(ownerUserId, appPart, item.getName());
if (service != null) {
result.put(item.getName(), service);
}
Expand All @@ -137,9 +137,9 @@ public Map<String, ExternalService> list(String ownerSub, String appPart) {
* user-authored definition is passed through {@code shaper} before being added (e.g. to strip secrets). Returns
* {@code inline} unchanged when the owner has none for this application.
*/
public Map<String, ExternalService> overlay(Map<String, ExternalService> inline, String ownerSub, String appPart,
public Map<String, ExternalService> overlay(Map<String, ExternalService> inline, String ownerUserId, String appPart,
Function<ExternalService, ExternalService> shaper) {
Map<String, ExternalService> userServices = list(ownerSub, appPart);
Map<String, ExternalService> userServices = list(ownerUserId, appPart);
if (userServices.isEmpty()) {
return inline;
}
Expand All @@ -151,8 +151,8 @@ public Map<String, ExternalService> overlay(Map<String, ExternalService> inline,
return merged;
}

public void delete(String ownerSub, String appPart, String serviceId) {
ResourceDescriptor resource = descriptor(ownerSub, appPart, serviceId);
public void delete(String ownerUserId, String appPart, String serviceId) {
ResourceDescriptor resource = descriptor(ownerUserId, appPart, serviceId);
if (!resourceService.deleteResource(resource, EtagHeader.ANY)) {
throw new ResourceNotFoundException("External service '%s' not found".formatted(serviceId));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ public static BucketInfo getUserBucketInfo(ProxyContext proxyContext) {
}

/**
* Builds the USER bucket for an arbitrary owner sub (not the caller). Used by the on-behalf-of (OBO)
* Builds the USER bucket for an arbitrary owner user id (not the caller). Used by the on-behalf-of (OBO)
* path, where the actor (caller) and the credential owner differ.
*/
public static BucketInfo getUserBucketInfoForUser(ProxyContext proxyContext, String ownerSub) {
String location = BucketBuilder.USER_BUCKET_PATTERN.formatted(ownerSub);
public static BucketInfo getUserBucketInfoForUser(ProxyContext proxyContext, String ownerUserId) {
String location = BucketBuilder.USER_BUCKET_PATTERN.formatted(ownerUserId);
String name = proxyContext.getProxy().getEncryptionService().encrypt(location);
return new BucketInfo(name, location);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,17 @@ private static String normalizeResourceId(boolean configApp, String appPart, Str

/**
* Builds a USER-only {@link CredentialsLocator} for an external-service scope, resolving the USER
* bucket from the given {@code ownerSub} rather than the caller. Used by the on-behalf-of (OBO)
* bucket from the given {@code ownerUserId} rather than the caller. Used by the on-behalf-of (OBO)
* retrieval path, where the actor (caller) differs from the credential owner. The resource id is
* normalized identically to {@link #fromExternalServiceScope} so it reads exactly where sign-in wrote.
* Carries only the USER level, so no APPLICATION/GLOBAL fallback is structurally possible (fail-closed).
*/
public static CredentialsLocator fromExternalServiceScopeForOwner(String scopeId, String ownerSub, ProxyContext proxyContext) {
public static CredentialsLocator fromExternalServiceScopeForOwner(String scopeId, String ownerUserId, ProxyContext proxyContext) {
String[] parts = parseExternalServiceScope(scopeId);
boolean configApp = proxyContext.getConfig().isDeploymentExists(parts[0]);

Map<CredentialsLevel, BucketInfo> bucketInfo = new EnumMap<>(CredentialsLevel.class);
bucketInfo.put(CredentialsLevel.USER, CredentialsDescriptorFactory.getUserBucketInfoForUser(proxyContext, ownerSub));
bucketInfo.put(CredentialsLevel.USER, CredentialsDescriptorFactory.getUserBucketInfoForUser(proxyContext, ownerUserId));
String resourceId = normalizeResourceId(configApp, parts[0], parts[1]);
return new CredentialsLocator(resourceId, bucketInfo);
}
Expand Down
Loading
Loading