From 09ae8bb2b1f8190c0255676e6d8ab69cc75ba44c Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 21 Aug 2026 13:03:53 +0200 Subject: [PATCH] feat(iam): add CEL allow-policy evaluator Adds the disabled-by-default IAM allow-policy domain model, a restricted Nessie CEL-Java Conditions profile, principal resolution, role expansion, and pure policy evaluation. The CEL environment exposes only the Version 1 declarations and runtime overloads, with unsupported or failed conditions non-granting. - [x] New feature (`feat:`) Models the supported Cloud Storage IAM Conditions subset without introducing a general authentication system or changing the default no-auth emulator behavior. Conditional bindings require Uniform Bucket-Level Access. Bucket-policy APIs and enforcement are introduced by later commits. ACL authorization, project-policy inheritance, custom roles, full UBLA lifecycle behavior, and CEL profile expansion (including `extract` and regex) remain out of scope. Focused CEL, evaluator, normalizer, principal-resolver, configuration, and UBLA tests cover the new pure evaluation surface. --- .../configuration/advanced/application-yml.md | 2 + docs/configuration/environment-variables.md | 1 + docs/services/iam.md | 6 + pom.xml | 26 +++ .../io/floci/gcp/config/EmulatorConfig.java | 8 + .../gcp/services/gcs/GcsGrpcController.java | 4 +- .../floci/gcp/services/gcs/GcsGrpcMapper.java | 146 +++++++++++- .../io/floci/gcp/services/gcs/GcsService.java | 133 +++++++++++ .../gcp/services/gcs/model/GcsBucket.java | 32 +++ .../io/floci/gcp/services/iam/IamBinding.java | 11 + .../floci/gcp/services/iam/IamCondition.java | 8 + .../services/iam/IamConditionEvaluator.java | 11 + .../io/floci/gcp/services/iam/IamPolicy.java | 11 + .../gcp/services/iam/IamPolicyEvaluator.java | 62 +++++ .../gcp/services/iam/IamPolicyNormalizer.java | 95 ++++++++ .../floci/gcp/services/iam/IamPrincipal.java | 28 +++ .../services/iam/IamPrincipalResolver.java | 74 ++++++ .../floci/gcp/services/iam/IamResource.java | 42 ++++ .../services/iam/IamResourceHierarchy.java | 14 ++ .../gcp/services/iam/IamRoleCatalog.java | 43 ++++ .../iam/NessieIamConditionEvaluator.java | 169 ++++++++++++++ src/main/resources/application.yml | 1 + .../services/gcs/GcsGrpcControllerTest.java | 218 ++++++++++++++++++ .../gcp/services/gcs/GcsServiceTest.java | 57 +++++ ...mBucketLevelAccessRestIntegrationTest.java | 134 +++++++++++ .../iam/IamAuthorizationModeConfigTest.java | 33 +++ .../services/iam/IamPolicyEvaluatorTest.java | 100 ++++++++ .../services/iam/IamPolicyNormalizerTest.java | 111 +++++++++ .../iam/IamPrincipalResolverTest.java | 67 ++++++ .../iam/NessieIamConditionEvaluatorTest.java | 100 ++++++++ src/test/resources/application.yml | 3 + 31 files changed, 1746 insertions(+), 4 deletions(-) create mode 100644 src/main/java/io/floci/gcp/services/iam/IamBinding.java create mode 100644 src/main/java/io/floci/gcp/services/iam/IamCondition.java create mode 100644 src/main/java/io/floci/gcp/services/iam/IamConditionEvaluator.java create mode 100644 src/main/java/io/floci/gcp/services/iam/IamPolicy.java create mode 100644 src/main/java/io/floci/gcp/services/iam/IamPolicyEvaluator.java create mode 100644 src/main/java/io/floci/gcp/services/iam/IamPolicyNormalizer.java create mode 100644 src/main/java/io/floci/gcp/services/iam/IamPrincipal.java create mode 100644 src/main/java/io/floci/gcp/services/iam/IamPrincipalResolver.java create mode 100644 src/main/java/io/floci/gcp/services/iam/IamResource.java create mode 100644 src/main/java/io/floci/gcp/services/iam/IamResourceHierarchy.java create mode 100644 src/main/java/io/floci/gcp/services/iam/IamRoleCatalog.java create mode 100644 src/main/java/io/floci/gcp/services/iam/NessieIamConditionEvaluator.java create mode 100644 src/test/java/io/floci/gcp/services/gcs/GcsUniformBucketLevelAccessRestIntegrationTest.java create mode 100644 src/test/java/io/floci/gcp/services/iam/IamAuthorizationModeConfigTest.java create mode 100644 src/test/java/io/floci/gcp/services/iam/IamPolicyEvaluatorTest.java create mode 100644 src/test/java/io/floci/gcp/services/iam/IamPolicyNormalizerTest.java create mode 100644 src/test/java/io/floci/gcp/services/iam/IamPrincipalResolverTest.java create mode 100644 src/test/java/io/floci/gcp/services/iam/NessieIamConditionEvaluatorTest.java diff --git a/docs/configuration/advanced/application-yml.md b/docs/configuration/advanced/application-yml.md index 95f9cb0d..447b7b87 100644 --- a/docs/configuration/advanced/application-yml.md +++ b/docs/configuration/advanced/application-yml.md @@ -168,6 +168,7 @@ floci-gcp: enabled: false iam: enabled: false + authorization-mode: disabled # disabled | enforce ``` Via environment variable: @@ -175,6 +176,7 @@ Via environment variable: ```bash FLOCI_GCP_SERVICES_DATASTORE_ENABLED=false FLOCI_GCP_SERVICES_IAM_ENABLED=false +FLOCI_GCP_SERVICES_IAM_AUTHORIZATION_MODE=disabled ``` ## Logging diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 6511fa5d..1267f18b 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -80,6 +80,7 @@ Each service can be toggled independently. All are enabled by default. | `FLOCI_GCP_SERVICES_DATASTORE_ENABLED` | `true` | Datastore | | `FLOCI_GCP_SERVICES_SECRETMANAGER_ENABLED` | `true` | Secret Manager | | `FLOCI_GCP_SERVICES_IAM_ENABLED` | `true` | IAM | +| `FLOCI_GCP_SERVICES_IAM_AUTHORIZATION_MODE` | `disabled` | IAM allow-policy evaluation mode. `disabled` preserves no-auth behavior; `enforce` is reserved for the forthcoming supported evaluation surface | | `FLOCI_GCP_SERVICES_IAMCREDENTIALS_ENABLED` | `true` | IAM Service Account Credentials (`generateAccessToken`) | | `FLOCI_GCP_SERVICES_STS_ENABLED` | `true` | Security Token Service (STS) | | `FLOCI_GCP_SERVICES_LOGGING_ENABLED` | `true` | Cloud Logging | diff --git a/docs/services/iam.md b/docs/services/iam.md index d13f0f6f..196b7126 100644 --- a/docs/services/iam.md +++ b/docs/services/iam.md @@ -7,6 +7,12 @@ floci-gcp emulates Google Cloud IAM over REST JSON using the real GCP IAM API. | Variable | Default | Description | |---|---|---| | `FLOCI_GCP_SERVICES_IAM_ENABLED` | `true` | Enable/disable IAM | +| `FLOCI_GCP_SERVICES_IAM_AUTHORIZATION_MODE` | `disabled` | IAM allow-policy evaluation mode. `disabled` preserves current no-auth behavior; `enforce` is reserved for the forthcoming supported evaluation surface | + +`authorization-mode` defaults to `disabled`. IAM policy storage and policy-shaped +responses remain available in that mode, but they do not restrict requests. The +setting is introduced ahead of the evaluator; this slice does not enforce +policies even when it is configured as `enforce`. ## Quick Start diff --git a/pom.xml b/pom.xml index 983b0269..471111d8 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,24 @@ pom import + + org.projectnessie.cel + cel-bom + 0.8.0 + pom + import + + + + com.google.protobuf + protobuf-java + 4.35.1 + + + com.google.protobuf + protobuf-java-util + 4.35.1 + @@ -256,6 +274,14 @@ rest-assured test + + org.projectnessie.cel + cel-core + + + org.projectnessie.cel + cel-generated-pb + diff --git a/src/main/java/io/floci/gcp/config/EmulatorConfig.java b/src/main/java/io/floci/gcp/config/EmulatorConfig.java index 5bf1fbb8..21671f7b 100644 --- a/src/main/java/io/floci/gcp/config/EmulatorConfig.java +++ b/src/main/java/io/floci/gcp/config/EmulatorConfig.java @@ -264,6 +264,14 @@ interface DatastoreServiceConfig { interface IamServiceConfig { @WithDefault("true") boolean enabled(); + + @WithDefault("disabled") + IamAuthorizationMode authorizationMode(); + } + + enum IamAuthorizationMode { + DISABLED, + ENFORCE } interface IamCredentialsServiceConfig { diff --git a/src/main/java/io/floci/gcp/services/gcs/GcsGrpcController.java b/src/main/java/io/floci/gcp/services/gcs/GcsGrpcController.java index 128265ad..4f95690a 100644 --- a/src/main/java/io/floci/gcp/services/gcs/GcsGrpcController.java +++ b/src/main/java/io/floci/gcp/services/gcs/GcsGrpcController.java @@ -128,9 +128,9 @@ public void updateBucket(UpdateBucketRequest request, StreamObserver obs if (request.getUpdateMask().getPathsCount() == 0) { throw GcpException.invalidArgument("update_mask is required"); } - return GcsGrpcMapper.toProto(service.updateBucket(bucketId, + return GcsGrpcMapper.toProto(service.updateBucketWithResolvedFields(bucketId, GcsGrpcMapper.bucketUpdateFields( - request.getBucket(), request.getUpdateMask().getPathsList()))); + current, request.getBucket(), request.getUpdateMask().getPathsList()))); }); } diff --git a/src/main/java/io/floci/gcp/services/gcs/GcsGrpcMapper.java b/src/main/java/io/floci/gcp/services/gcs/GcsGrpcMapper.java index 74e14ee5..35e25827 100644 --- a/src/main/java/io/floci/gcp/services/gcs/GcsGrpcMapper.java +++ b/src/main/java/io/floci/gcp/services/gcs/GcsGrpcMapper.java @@ -66,6 +66,26 @@ static Bucket toProto(GcsBucket stored) { value.setVersioning(Bucket.Versioning.newBuilder() .setEnabled(Boolean.TRUE.equals(stored.getVersioning().get("enabled")))); } + if (stored.getCanonicalIamConfiguration() != null) { + Bucket.IamConfig.Builder iamConfig = Bucket.IamConfig.newBuilder(); + if (stored.getCanonicalIamConfiguration().get("uniformBucketLevelAccess") + instanceof Map access) { + Bucket.IamConfig.UniformBucketLevelAccess.Builder uniformAccess = + Bucket.IamConfig.UniformBucketLevelAccess.newBuilder() + .setEnabled(Boolean.TRUE.equals(access.get("enabled"))); + if (access.get("lockedTime") instanceof String lockedTime) { + timestamp(lockedTime).ifPresent(uniformAccess::setLockTime); + } + iamConfig.setUniformBucketLevelAccess(uniformAccess); + } + if (stored.getCanonicalIamConfiguration().get("publicAccessPrevention") + instanceof String prevention) { + iamConfig.setPublicAccessPrevention(prevention); + } + if (iamConfig.hasUniformBucketLevelAccess() || !iamConfig.getPublicAccessPrevention().isBlank()) { + value.setIamConfig(iamConfig); + } + } return value.build(); } @@ -83,22 +103,37 @@ static Map bucketCreateFields(Bucket bucket) { if (bucket.hasVersioning()) { body.put("versioning", Map.of("enabled", bucket.getVersioning().getEnabled())); } + iamConfiguration(bucket).ifPresent(iamConfiguration -> body.put("iamConfiguration", iamConfiguration)); body.put("defaultEventBasedHold", bucket.getDefaultEventBasedHold()); return body; } - static Map bucketUpdateFields(Bucket bucket, + static Map bucketUpdateFields(GcsBucket current, Bucket bucket, java.util.List paths) { - Map all = bucketCreateFields(bucket); if (paths.contains("*")) { + Map all = bucketCreateFields(bucket); + all.putIfAbsent("iamConfiguration", Map.of()); return all; } Map patch = new LinkedHashMap<>(); + boolean mergeIamConfigurationMessage = false; + boolean mergeUniformBucketLevelAccessMessage = false; + boolean updateUniformBucketLevelAccessEnabled = false; + boolean updateUniformBucketLevelAccessLockTime = false; + boolean updatePublicAccessPrevention = false; for (String path : paths) { switch (path) { case "labels" -> patch.put("labels", new LinkedHashMap<>(bucket.getLabelsMap())); case "versioning", "versioning.enabled" -> patch.put("versioning", Map.of("enabled", bucket.getVersioning().getEnabled())); + case "iam_config" -> mergeIamConfigurationMessage = true; + case "iam_config.uniform_bucket_level_access" -> + mergeUniformBucketLevelAccessMessage = true; + case "iam_config.uniform_bucket_level_access.enabled" -> + updateUniformBucketLevelAccessEnabled = true; + case "iam_config.uniform_bucket_level_access.lock_time" -> + updateUniformBucketLevelAccessLockTime = true; + case "iam_config.public_access_prevention" -> updatePublicAccessPrevention = true; case "storage_class" -> patch.put("storageClass", bucket.getStorageClass()); case "default_event_based_hold" -> patch.put("defaultEventBasedHold", bucket.getDefaultEventBasedHold()); @@ -111,9 +146,112 @@ static Map bucketUpdateFields(Bucket bucket, } } } + if (mergeIamConfigurationMessage || mergeUniformBucketLevelAccessMessage + || updateUniformBucketLevelAccessEnabled || updateUniformBucketLevelAccessLockTime + || updatePublicAccessPrevention) { + patch.put("iamConfiguration", mergeIamConfiguration( + current.getCanonicalIamConfiguration(), bucket, + mergeIamConfigurationMessage, mergeUniformBucketLevelAccessMessage, + updateUniformBucketLevelAccessEnabled, updateUniformBucketLevelAccessLockTime, + updatePublicAccessPrevention)); + } return patch; } + private static Map mergeIamConfiguration( + Map current, Bucket bucket, + boolean mergeIamConfigurationMessage, boolean mergeUniformBucketLevelAccessMessage, + boolean updateUniformBucketLevelAccessEnabled, + boolean updateUniformBucketLevelAccessLockTime, + boolean updatePublicAccessPrevention) { + Map merged = current == null + ? new LinkedHashMap<>() + : new LinkedHashMap<>(current); + Bucket.IamConfig requested = bucket.getIamConfig(); + // Protobuf FieldMask update semantics merge a selected message into the + // stored message, preserving omitted siblings. A leaf mask can clear one field. + if (mergeIamConfigurationMessage) { + if (bucket.hasIamConfig()) { + if (requested.hasUniformBucketLevelAccess()) { + merged.put("uniformBucketLevelAccess", mergeUniformBucketLevelAccess( + merged.get("uniformBucketLevelAccess"), + requested.getUniformBucketLevelAccess())); + } + if (!requested.getPublicAccessPrevention().isBlank()) { + merged.put("publicAccessPrevention", requested.getPublicAccessPrevention()); + } + } + return merged; + } + + if (mergeUniformBucketLevelAccessMessage) { + if (requested.hasUniformBucketLevelAccess()) { + merged.put("uniformBucketLevelAccess", mergeUniformBucketLevelAccess( + merged.get("uniformBucketLevelAccess"), + requested.getUniformBucketLevelAccess())); + } + } else if (updateUniformBucketLevelAccessEnabled || updateUniformBucketLevelAccessLockTime) { + Map uniformAccess = mutableMap( + merged.get("uniformBucketLevelAccess")); + if (updateUniformBucketLevelAccessEnabled) { + uniformAccess.put("enabled", requested.getUniformBucketLevelAccess().getEnabled()); + } + if (updateUniformBucketLevelAccessLockTime) { + if (requested.getUniformBucketLevelAccess().hasLockTime()) { + uniformAccess.put("lockedTime", + instant(requested.getUniformBucketLevelAccess().getLockTime())); + } else { + uniformAccess.remove("lockedTime"); + } + } + merged.put("uniformBucketLevelAccess", uniformAccess); + } + + if (updatePublicAccessPrevention) { + if (requested.getPublicAccessPrevention().isBlank()) { + merged.remove("publicAccessPrevention"); + } else { + merged.put("publicAccessPrevention", requested.getPublicAccessPrevention()); + } + } + return merged; + } + + private static Map mergeUniformBucketLevelAccess( + java.lang.Object current, Bucket.IamConfig.UniformBucketLevelAccess requested) { + Map merged = mutableMap(current); + // Official clients can select the parent IAM field while sending only this + // submessage. Its presence makes the plain proto3 false value intentional. + merged.put("enabled", requested.getEnabled()); + return merged; + } + + private static Map mutableMap(java.lang.Object value) { + Map copy = new LinkedHashMap<>(); + if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + copy.put(String.valueOf(entry.getKey()), entry.getValue()); + } + } + return copy; + } + + private static java.util.Optional> iamConfiguration(Bucket bucket) { + if (!bucket.hasIamConfig()) { + return java.util.Optional.empty(); + } + Map iamConfiguration = new LinkedHashMap<>(); + if (bucket.getIamConfig().hasUniformBucketLevelAccess()) { + Map uniformAccess = new LinkedHashMap<>(); + uniformAccess.put("enabled", bucket.getIamConfig().getUniformBucketLevelAccess().getEnabled()); + iamConfiguration.put("uniformBucketLevelAccess", uniformAccess); + } + if (!bucket.getIamConfig().getPublicAccessPrevention().isBlank()) { + iamConfiguration.put("publicAccessPrevention", bucket.getIamConfig().getPublicAccessPrevention()); + } + return iamConfiguration.isEmpty() ? java.util.Optional.empty() : java.util.Optional.of(iamConfiguration); + } + static com.google.storage.v2.Object toProto(GcsObjectMeta stored) { com.google.storage.v2.Object.Builder value = com.google.storage.v2.Object.newBuilder() .setName(stored.getName()) @@ -228,6 +366,10 @@ static java.util.Optional timestamp(String value) { .setSeconds(instant.getEpochSecond()).setNanos(instant.getNano()).build()); } + private static String instant(Timestamp timestamp) { + return Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos()).toString(); + } + private static long parseLong(String value) { return value == null || value.isBlank() ? 0 : Long.parseLong(value); } diff --git a/src/main/java/io/floci/gcp/services/gcs/GcsService.java b/src/main/java/io/floci/gcp/services/gcs/GcsService.java index 5dfd4bd4..d27580c8 100644 --- a/src/main/java/io/floci/gcp/services/gcs/GcsService.java +++ b/src/main/java/io/floci/gcp/services/gcs/GcsService.java @@ -63,6 +63,7 @@ public class GcsService { private static final Logger LOG = Logger.getLogger(GcsService.class); private static final int OBJECT_LOCK_COUNT = 256; private static final int COMPLETED_RESUMABLE_UPLOAD_HISTORY = 1024; + private static final int UNIFORM_BUCKET_LEVEL_ACCESS_LOCK_DAYS = 90; private final StorageBackend bucketStore; private final StorageBackend objectMetaStore; @@ -222,6 +223,10 @@ public GcsBucket createBucket(String name, String projectId, String baseUrl, if (body.containsKey("softDeletePolicy")) { bucket.setSoftDeletePolicy((Map) body.get("softDeletePolicy")); } + if (body.containsKey("iamConfiguration")) { + bucket.setIamConfiguration(updateIamConfiguration( + null, (Map) body.get("iamConfiguration"), true)); + } if (body.containsKey("defaultEventBasedHold")) { bucket.setDefaultEventBasedHold((Boolean) body.get("defaultEventBasedHold")); } @@ -238,8 +243,24 @@ public GcsBucket getBucket(String name) { @SuppressWarnings("unchecked") public GcsBucket updateBucket(String name, Map patch) { + return applyBucketUpdate(name, patch, false); + } + + GcsBucket updateBucketWithResolvedFields(String name, Map fields) { + return applyBucketUpdate(name, fields, true); + } + + @SuppressWarnings("unchecked") + private GcsBucket applyBucketUpdate(String name, Map patch, + boolean replaceIamConfiguration) { LOG.debugf("updateBucket name=%s", name); GcsBucket bucket = getBucket(name); + Map updatedIamConfiguration = patch.containsKey("iamConfiguration") + ? updateIamConfiguration( + bucket.getCanonicalIamConfiguration(), + (Map) patch.get("iamConfiguration"), + replaceIamConfiguration) + : null; if (patch.containsKey("labels")) { bucket.setLabels((Map) patch.get("labels")); } @@ -259,6 +280,9 @@ public GcsBucket updateBucket(String name, Map patch) { if (patch.containsKey("softDeletePolicy")) { bucket.setSoftDeletePolicy((Map) patch.get("softDeletePolicy")); } + if (patch.containsKey("iamConfiguration")) { + bucket.setIamConfiguration(updatedIamConfiguration); + } if (patch.containsKey("storageClass")) { bucket.setStorageClass((String) patch.get("storageClass")); } @@ -272,6 +296,115 @@ public GcsBucket updateBucket(String name, Map patch) { return bucket; } + private Map updateIamConfiguration(Map current, + Map requested, boolean replace) { + if (requested == null) { + validateUniformBucketLevelAccessChange(current, null); + return null; + } + Map merged = replace + ? new LinkedHashMap<>() + : mutableNestedMap(current); + Map sanitized = mutableNestedMap(requested); + boolean hasBucketPolicyOnly = sanitized.containsKey("bucketPolicyOnly"); + Object bucketPolicyOnly = sanitized.remove("bucketPolicyOnly"); + if (!sanitized.containsKey("uniformBucketLevelAccess") && hasBucketPolicyOnly) { + sanitized.put("uniformBucketLevelAccess", bucketPolicyOnly); + } + if (sanitized.get("uniformBucketLevelAccess") instanceof Map requestedAccess) { + Map writableAccess = mutableNestedMap(requestedAccess); + // GCS generates lockedTime when uniform access is enabled. Client values are output-only. + writableAccess.remove("lockedTime"); + sanitized.put("uniformBucketLevelAccess", writableAccess); + } + mergeNestedMap(merged, sanitized); + validatePublicAccessPrevention(merged); + normalizeUniformBucketLevelAccess(current, merged); + return merged; + } + + private static void validatePublicAccessPrevention(Map iamConfiguration) { + if (!iamConfiguration.containsKey("publicAccessPrevention")) { + return; + } + Object value = iamConfiguration.get("publicAccessPrevention"); + if (!(value instanceof String prevention) + || !("inherited".equals(prevention) || "enforced".equals(prevention))) { + throw GcpException.invalidArgument( + "publicAccessPrevention must be inherited or enforced"); + } + } + + private void normalizeUniformBucketLevelAccess(Map current, + Map updated) { + validateUniformBucketLevelAccessChange(current, updated); + if (!(updated.get("uniformBucketLevelAccess") instanceof Map access)) { + return; + } + Map normalized = mutableNestedMap(access); + boolean enabled = Boolean.TRUE.equals(normalized.get("enabled")); + boolean wasEnabled = current != null + && current.get("uniformBucketLevelAccess") instanceof Map currentAccess + && Boolean.TRUE.equals(currentAccess.get("enabled")); + if (!enabled) { + normalized.remove("lockedTime"); + } else if (wasEnabled + && current.get("uniformBucketLevelAccess") instanceof Map currentAccess + && currentAccess.get("lockedTime") instanceof String lockedTime) { + normalized.put("lockedTime", lockedTime); + } else { + normalized.put("lockedTime", Instant.parse(nowTimestamp()) + .plus(UNIFORM_BUCKET_LEVEL_ACCESS_LOCK_DAYS, ChronoUnit.DAYS) + .toString()); + } + updated.put("uniformBucketLevelAccess", normalized); + } + + private void validateUniformBucketLevelAccessChange(Map current, + Map updated) { + if (current == null + || !(current.get("uniformBucketLevelAccess") instanceof Map currentAccess) + || !Boolean.TRUE.equals(currentAccess.get("enabled")) + || !(currentAccess.get("lockedTime") instanceof String lockedTime)) { + return; + } + boolean remainsEnabled = updated != null + && updated.get("uniformBucketLevelAccess") instanceof Map updatedAccess + && Boolean.TRUE.equals(updatedAccess.get("enabled")); + if (!remainsEnabled && !Instant.parse(lockedTime).isAfter(Instant.parse(nowTimestamp()))) { + throw GcpException.invalidArgument( + "Uniform bucket-level access cannot be disabled after its locked time."); + } + } + + private static void mergeNestedMap(Map target, Map patch) { + for (Map.Entry entry : patch.entrySet()) { + if (entry.getValue() == null) { + target.remove(entry.getKey()); + } else if (entry.getValue() instanceof Map nestedPatch + && target.get(entry.getKey()) instanceof Map nestedTarget) { + Map merged = mutableNestedMap(nestedTarget); + mergeNestedMap(merged, mutableNestedMap(nestedPatch)); + target.put(entry.getKey(), merged); + } else { + target.put(entry.getKey(), entry.getValue()); + } + } + } + + private static Map mutableNestedMap(Map source) { + Map copy = new LinkedHashMap<>(); + if (source != null) { + for (Map.Entry entry : source.entrySet()) { + Object value = entry.getValue() instanceof Map nested + ? mutableNestedMap(nested) + : entry.getValue(); + copy.put(String.valueOf(entry.getKey()), value); + } + } + return copy; + } + public void deleteBucket(String name) { LOG.debugf("deleteBucket name=%s", name); if (!deleteBucketIfEmpty(name)) { diff --git a/src/main/java/io/floci/gcp/services/gcs/model/GcsBucket.java b/src/main/java/io/floci/gcp/services/gcs/model/GcsBucket.java index 8e06a3ca..43418955 100644 --- a/src/main/java/io/floci/gcp/services/gcs/model/GcsBucket.java +++ b/src/main/java/io/floci/gcp/services/gcs/model/GcsBucket.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import io.quarkus.runtime.annotations.RegisterForReflection; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -31,6 +32,7 @@ public class GcsBucket { // Presence of a policy with a non-zero retentionDurationSeconds turns on soft delete: // a deleted object is retained and can be restored until it is hard-deleted. private Map softDeletePolicy; + private Map iamConfiguration; private Boolean defaultEventBasedHold; public String getKind() { return kind; } @@ -88,6 +90,36 @@ public class GcsBucket { public Map getSoftDeletePolicy() { return softDeletePolicy; } public void setSoftDeletePolicy(Map softDeletePolicy) { this.softDeletePolicy = softDeletePolicy; } + public Map getIamConfiguration() { + if (iamConfiguration == null) { + return null; + } + Map response = new LinkedHashMap<>(iamConfiguration); + if (iamConfiguration.containsKey("uniformBucketLevelAccess")) { + response.put("bucketPolicyOnly", iamConfiguration.get("uniformBucketLevelAccess")); + } + return response; + } + + @JsonIgnore + public Map getCanonicalIamConfiguration() { + return iamConfiguration; + } + + public void setIamConfiguration(Map iamConfiguration) { + if (iamConfiguration == null) { + this.iamConfiguration = null; + return; + } + Map canonical = new LinkedHashMap<>(iamConfiguration); + boolean hasBucketPolicyOnly = canonical.containsKey("bucketPolicyOnly"); + Object bucketPolicyOnly = canonical.remove("bucketPolicyOnly"); + if (!canonical.containsKey("uniformBucketLevelAccess") && hasBucketPolicyOnly) { + canonical.put("uniformBucketLevelAccess", bucketPolicyOnly); + } + this.iamConfiguration = canonical; + } + public Boolean getDefaultEventBasedHold() { return defaultEventBasedHold; } public void setDefaultEventBasedHold(Boolean defaultEventBasedHold) { this.defaultEventBasedHold = defaultEventBasedHold; } } diff --git a/src/main/java/io/floci/gcp/services/iam/IamBinding.java b/src/main/java/io/floci/gcp/services/iam/IamBinding.java new file mode 100644 index 00000000..2fae636a --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iam/IamBinding.java @@ -0,0 +1,11 @@ +package io.floci.gcp.services.iam; + +import java.util.List; + +/** A normalized IAM allow-policy binding. */ +public record IamBinding(String role, List members, IamCondition condition) { + + public IamBinding { + members = List.copyOf(members); + } +} diff --git a/src/main/java/io/floci/gcp/services/iam/IamCondition.java b/src/main/java/io/floci/gcp/services/iam/IamCondition.java new file mode 100644 index 00000000..96149a50 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iam/IamCondition.java @@ -0,0 +1,8 @@ +package io.floci.gcp.services.iam; + +/** + * A validated IAM allow-binding condition. CEL compilation is intentionally deferred to + * {@link IamConditionEvaluator} in the evaluator slice. + */ +public record IamCondition(String title, String expression, String description) { +} diff --git a/src/main/java/io/floci/gcp/services/iam/IamConditionEvaluator.java b/src/main/java/io/floci/gcp/services/iam/IamConditionEvaluator.java new file mode 100644 index 00000000..a6b4d409 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iam/IamConditionEvaluator.java @@ -0,0 +1,11 @@ +package io.floci.gcp.services.iam; + +/** Evaluates the deliberately restricted IAM Conditions profile. */ +public interface IamConditionEvaluator { + + /** Checks that a condition is supported before its policy is persisted. */ + void validate(IamCondition condition); + + /** Returns {@code true} only when the condition evaluates to Boolean {@code true}. */ + boolean matches(IamCondition condition, IamResource resource); +} diff --git a/src/main/java/io/floci/gcp/services/iam/IamPolicy.java b/src/main/java/io/floci/gcp/services/iam/IamPolicy.java new file mode 100644 index 00000000..1dd40949 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iam/IamPolicy.java @@ -0,0 +1,11 @@ +package io.floci.gcp.services.iam; + +import java.util.List; + +/** A normalized IAM allow policy used by the future evaluator. */ +public record IamPolicy(int version, List bindings, String etag) { + + public IamPolicy { + bindings = List.copyOf(bindings); + } +} diff --git a/src/main/java/io/floci/gcp/services/iam/IamPolicyEvaluator.java b/src/main/java/io/floci/gcp/services/iam/IamPolicyEvaluator.java new file mode 100644 index 00000000..b1b4faa8 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iam/IamPolicyEvaluator.java @@ -0,0 +1,62 @@ +package io.floci.gcp.services.iam; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.Map; +import java.util.Objects; + +/** Pure IAM allow-policy evaluator with no HTTP, storage, or token parsing dependency. */ +@ApplicationScoped +public class IamPolicyEvaluator { + + private final IamRoleCatalog roleCatalog; + private final IamResourceHierarchy resourceHierarchy; + private final IamConditionEvaluator conditionEvaluator; + + @Inject + public IamPolicyEvaluator(IamRoleCatalog roleCatalog, IamResourceHierarchy resourceHierarchy, + IamConditionEvaluator conditionEvaluator) { + this.roleCatalog = roleCatalog; + this.resourceHierarchy = resourceHierarchy; + this.conditionEvaluator = conditionEvaluator; + } + + public boolean isAllowed(IamPrincipal principal, String permission, IamResource resource, + Map policies) { + Objects.requireNonNull(principal, "principal"); + Objects.requireNonNull(permission, "permission"); + Objects.requireNonNull(resource, "resource"); + Objects.requireNonNull(policies, "policies"); + + for (String policyResource : resourceHierarchy.policyResourcesFor(resource)) { + IamPolicy policy = policies.get(policyResource); + if (policy == null) { + continue; + } + for (IamBinding binding : policy.bindings()) { + if (isValidForEvaluation(policy, binding) && matches(principal, binding) + && roleCatalog.grants(binding.role(), permission) + && (binding.condition() == null || conditionEvaluator.matches(binding.condition(), resource))) { + return true; + } + } + } + return false; + } + + private static boolean matches(IamPrincipal principal, IamBinding binding) { + return binding.members().contains("allUsers") + || (principal.isAuthenticated() && binding.members().contains("allAuthenticatedUsers")) + || (principal.member() != null && binding.members().contains(principal.member())); + } + + private static boolean isValidForEvaluation(IamPolicy policy, IamBinding binding) { + if (binding.condition() == null) { + return true; + } + return policy.version() == 3 + && !binding.members().contains("allUsers") + && !binding.members().contains("allAuthenticatedUsers"); + } +} diff --git a/src/main/java/io/floci/gcp/services/iam/IamPolicyNormalizer.java b/src/main/java/io/floci/gcp/services/iam/IamPolicyNormalizer.java new file mode 100644 index 00000000..74d0dae6 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iam/IamPolicyNormalizer.java @@ -0,0 +1,95 @@ +package io.floci.gcp.services.iam; + +import io.floci.gcp.core.common.GcpException; +import io.floci.gcp.services.iam.model.StoredPolicy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Converts the wire-compatible stored policy DTO into immutable evaluator input. + * + *

This class validates only policy structure. CEL profile validation belongs to the condition + * evaluator, and member/role support belongs to the evaluator's explicitly limited catalogs.

+ */ +public final class IamPolicyNormalizer { + + private static final int VERSION_1 = 1; + private static final int VERSION_3 = 3; + + private IamPolicyNormalizer() { + } + + public static IamPolicy normalize(StoredPolicy storedPolicy) { + if (storedPolicy == null) { + throw invalidPolicy("policy is required"); + } + int version = storedPolicy.getVersion() == 0 ? VERSION_1 : storedPolicy.getVersion(); + if (version != VERSION_1 && version != VERSION_3) { + throw invalidPolicy("policy version must be 1 or 3"); + } + + List storedBindings = storedPolicy.getBindings(); + if (storedBindings == null) { + storedBindings = List.of(); + } + + List bindings = new ArrayList<>(storedBindings.size()); + for (Object storedBinding : storedBindings) { + bindings.add(normalizeBinding(storedBinding, version)); + } + return new IamPolicy(version, bindings, storedPolicy.getEtag()); + } + + private static IamBinding normalizeBinding(Object value, int version) { + if (!(value instanceof Map storedBinding)) { + throw invalidPolicy("policy binding must be an object"); + } + String role = requiredString(storedBinding.get("role"), "policy binding role"); + List members = normalizeMembers(storedBinding.get("members")); + IamCondition condition = normalizeCondition(storedBinding.get("condition")); + if (condition != null && version != VERSION_3) { + throw invalidPolicy("conditional policy bindings require version 3"); + } + return new IamBinding(role, members, condition); + } + + private static List normalizeMembers(Object value) { + if (!(value instanceof List rawMembers) || rawMembers.isEmpty()) { + throw invalidPolicy("policy binding members must be a non-empty list"); + } + List members = new ArrayList<>(rawMembers.size()); + for (Object rawMember : rawMembers) { + members.add(requiredString(rawMember, "policy binding member")); + } + return members; + } + + private static IamCondition normalizeCondition(Object value) { + if (value == null) { + return null; + } + if (!(value instanceof Map rawCondition)) { + throw invalidPolicy("policy binding condition must be an object"); + } + String title = requiredString(rawCondition.get("title"), "policy binding condition title"); + String expression = requiredString(rawCondition.get("expression"), + "policy binding condition expression"); + Object descriptionValue = rawCondition.get("description"); + String description = descriptionValue == null ? null + : requiredString(descriptionValue, "policy binding condition description"); + return new IamCondition(title, expression, description); + } + + private static String requiredString(Object value, String field) { + if (!(value instanceof String stringValue) || stringValue.isBlank()) { + throw invalidPolicy(field + " must be a non-blank string"); + } + return stringValue; + } + + private static GcpException invalidPolicy(String message) { + return GcpException.invalidArgument(message); + } +} diff --git a/src/main/java/io/floci/gcp/services/iam/IamPrincipal.java b/src/main/java/io/floci/gcp/services/iam/IamPrincipal.java new file mode 100644 index 00000000..09b8d25d --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iam/IamPrincipal.java @@ -0,0 +1,28 @@ +package io.floci.gcp.services.iam; + +/** Canonical principal identity used for IAM allow-binding membership checks. */ +public record IamPrincipal(String member) { + + private static final IamPrincipal ANONYMOUS = new IamPrincipal(null); + + public static IamPrincipal anonymous() { + return ANONYMOUS; + } + + public static IamPrincipal serviceAccount(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("service account email must not be blank"); + } + return new IamPrincipal("serviceAccount:" + email); + } + + public boolean isAuthenticated() { + return member != null; + } + + public IamPrincipal { + if (member != null && member.isBlank()) { + throw new IllegalArgumentException("principal member must not be blank"); + } + } +} diff --git a/src/main/java/io/floci/gcp/services/iam/IamPrincipalResolver.java b/src/main/java/io/floci/gcp/services/iam/IamPrincipalResolver.java new file mode 100644 index 00000000..4d477acb --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iam/IamPrincipalResolver.java @@ -0,0 +1,74 @@ +package io.floci.gcp.services.iam; + +import io.floci.gcp.services.credentials.CredentialTokenService; +import io.floci.gcp.services.credentials.StoredCredentialToken; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.Optional; + +/** Resolves only identities represented by valid Floci-issued credential tokens. */ +@ApplicationScoped +public class IamPrincipalResolver { + + private static final String BEARER_PREFIX = "Bearer "; + private static final String SERVICE_ACCOUNT_RESOURCE_MARKER = "/serviceAccounts/"; + + private final CredentialTokenService tokenService; + + @Inject + public IamPrincipalResolver(CredentialTokenService tokenService) { + this.tokenService = tokenService; + } + + public Resolution resolve(String authorization) { + Optional bearerToken = bearerToken(authorization); + if (bearerToken.isEmpty()) { + return Resolution.anonymous(); + } + Optional token = tokenService.lookupBearerToken(bearerToken.get()); + if (token.isEmpty()) { + return Resolution.anonymous(); + } + if (token.get().getTokenKind() == StoredCredentialToken.TokenKind.DOWNSCOPED) { + return Resolution.downscopedToken(); + } + return Resolution.authenticated(IamPrincipal.serviceAccount(normalizeServiceAccount(token.get().getPrincipal()))); + } + + private static Optional bearerToken(String authorization) { + if (authorization == null || authorization.isBlank() + || !authorization.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) { + return Optional.empty(); + } + String token = authorization.substring(BEARER_PREFIX.length()).trim(); + return token.isEmpty() ? Optional.empty() : Optional.of(token); + } + + private static String normalizeServiceAccount(String principal) { + if (principal == null || principal.isBlank()) { + throw new IllegalArgumentException("Floci impersonated token has no service account principal"); + } + if (principal.startsWith("serviceAccount:")) { + return principal.substring("serviceAccount:".length()); + } + int resourceMarker = principal.indexOf(SERVICE_ACCOUNT_RESOURCE_MARKER); + return resourceMarker >= 0 ? principal.substring(resourceMarker + SERVICE_ACCOUNT_RESOURCE_MARKER.length()) + : principal; + } + + public record Resolution(IamPrincipal principal, boolean downscoped) { + + private static Resolution anonymous() { + return new Resolution(IamPrincipal.anonymous(), false); + } + + private static Resolution downscopedToken() { + return new Resolution(IamPrincipal.anonymous(), true); + } + + private static Resolution authenticated(IamPrincipal principal) { + return new Resolution(principal, false); + } + } +} diff --git a/src/main/java/io/floci/gcp/services/iam/IamResource.java b/src/main/java/io/floci/gcp/services/iam/IamResource.java new file mode 100644 index 00000000..f8a47d5e --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iam/IamResource.java @@ -0,0 +1,42 @@ +package io.floci.gcp.services.iam; + +import java.util.Objects; + +/** + * Canonical resource data used for IAM Conditions and policy lookup. + * + *

The policy lookup key is deliberately distinct from the IAM resource name. Storage keys + * remain an implementation detail and must not be exposed to CEL expressions.

+ */ +public record IamResource(String service, String type, String name, String policyResource) { + + private static final String STORAGE_SERVICE = "storage.googleapis.com"; + private static final String BUCKET_TYPE = STORAGE_SERVICE + "/Bucket"; + private static final String OBJECT_TYPE = STORAGE_SERVICE + "/Object"; + + public IamResource { + Objects.requireNonNull(service, "service"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(policyResource, "policyResource"); + } + + public static IamResource gcsBucket(String bucket) { + requireBucket(bucket); + return new IamResource(STORAGE_SERVICE, BUCKET_TYPE, + "projects/_/buckets/" + bucket, "buckets/" + bucket); + } + + public static IamResource gcsObject(String bucket, String object) { + requireBucket(bucket); + Objects.requireNonNull(object, "object"); + return new IamResource(STORAGE_SERVICE, OBJECT_TYPE, + "projects/_/buckets/" + bucket + "/objects/" + object, "buckets/" + bucket); + } + + private static void requireBucket(String bucket) { + if (bucket == null || bucket.isBlank()) { + throw new IllegalArgumentException("bucket must not be blank"); + } + } +} diff --git a/src/main/java/io/floci/gcp/services/iam/IamResourceHierarchy.java b/src/main/java/io/floci/gcp/services/iam/IamResourceHierarchy.java new file mode 100644 index 00000000..8687a355 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iam/IamResourceHierarchy.java @@ -0,0 +1,14 @@ +package io.floci.gcp.services.iam; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.List; + +/** Returns policy resources applicable to a resource, from closest to farthest ancestor. */ +@ApplicationScoped +public class IamResourceHierarchy { + + public List policyResourcesFor(IamResource resource) { + return List.of(resource.policyResource()); + } +} diff --git a/src/main/java/io/floci/gcp/services/iam/IamRoleCatalog.java b/src/main/java/io/floci/gcp/services/iam/IamRoleCatalog.java new file mode 100644 index 00000000..74fe96e2 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iam/IamRoleCatalog.java @@ -0,0 +1,43 @@ +package io.floci.gcp.services.iam; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** The finite predefined-role subset supported by the IAM evaluation milestone. */ +@ApplicationScoped +public class IamRoleCatalog { + + private static final Set OBJECT_VIEWER = Set.of("storage.objects.get", "storage.objects.list"); + private static final Set OBJECT_CREATOR = Set.of("storage.objects.create"); + private static final Set OBJECT_ADMIN = Set.of( + "storage.objects.get", "storage.objects.list", "storage.objects.create", + "storage.objects.delete", "storage.objects.update", "storage.objects.move"); + private static final Set STORAGE_ADMIN = Set.of( + "storage.buckets.get", "storage.buckets.update", "storage.buckets.delete", + "storage.buckets.getIamPolicy", "storage.buckets.setIamPolicy", + "storage.objects.get", "storage.objects.list", "storage.objects.create", + "storage.objects.delete", "storage.objects.update", "storage.objects.move"); + + private final Map> permissionsByRole; + + public IamRoleCatalog() { + this(Map.of( + "roles/storage.objectViewer", OBJECT_VIEWER, + "roles/storage.objectCreator", OBJECT_CREATOR, + "roles/storage.objectAdmin", OBJECT_ADMIN, + "roles/storage.admin", STORAGE_ADMIN)); + } + + IamRoleCatalog(Map> permissionsByRole) { + Map> copiedPermissions = new LinkedHashMap<>(); + permissionsByRole.forEach((role, permissions) -> copiedPermissions.put(role, Set.copyOf(permissions))); + this.permissionsByRole = Map.copyOf(copiedPermissions); + } + + public boolean grants(String role, String permission) { + return permissionsByRole.getOrDefault(role, Set.of()).contains(permission); + } +} diff --git a/src/main/java/io/floci/gcp/services/iam/NessieIamConditionEvaluator.java b/src/main/java/io/floci/gcp/services/iam/NessieIamConditionEvaluator.java new file mode 100644 index 00000000..de7f5a9d --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iam/NessieIamConditionEvaluator.java @@ -0,0 +1,169 @@ +package io.floci.gcp.services.iam; + +import com.google.api.expr.v1alpha1.Decl; +import com.google.api.expr.v1alpha1.Type; +import io.floci.gcp.core.common.GcpException; +import jakarta.enterprise.context.ApplicationScoped; +import org.projectnessie.cel.Ast; +import org.projectnessie.cel.Env; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.Program; +import org.projectnessie.cel.ProgramOption; +import org.projectnessie.cel.checker.Decls; +import org.projectnessie.cel.common.operators.Operator; +import org.projectnessie.cel.common.types.Overloads; +import org.projectnessie.cel.interpreter.functions.Overload; + +import java.time.Clock; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Nessie CEL-Java implementation of Floci's Version 1 IAM Conditions profile. + * + *

The custom environment intentionally has no standard library. Only declarations and runtime + * overloads listed here are available, so unsupported functions fail type checking without + * Floci-side source or AST filtering.

+ */ +@ApplicationScoped +public class NessieIamConditionEvaluator implements IamConditionEvaluator { + + private static final int DEFAULT_CACHE_CAPACITY = 256; + private static final Set RUNTIME_OPERATORS = Set.of( + Operator.LogicalNot.id, + Operator.Less.id, + Overloads.TypeConvertTimestamp); + + private final Env environment; + private final Clock clock; + private final int cacheCapacity; + private final Map programs; + + public NessieIamConditionEvaluator() { + this(Clock.systemUTC(), DEFAULT_CACHE_CAPACITY); + } + + NessieIamConditionEvaluator(Clock clock, int cacheCapacity) { + this.clock = Objects.requireNonNull(clock, "clock"); + if (cacheCapacity <= 0) { + throw new IllegalArgumentException("cacheCapacity must be positive"); + } + this.cacheCapacity = cacheCapacity; + this.environment = Env.newCustomEnv(EnvOption.declarations(profileDeclarations())); + this.programs = new LinkedHashMap<>(cacheCapacity, 0.75f, true); + } + + @Override + public void validate(IamCondition condition) { + programFor(condition); + } + + @Override + public boolean matches(IamCondition condition, IamResource resource) { + Objects.requireNonNull(resource, "resource"); + try { + Program.EvalResult result = programFor(condition).eval(Map.of( + "resource.service", resource.service(), + "resource.type", resource.type(), + "resource.name", resource.name(), + "request.time", clock.instant())); + return result.getVal().value() instanceof Boolean value && value; + } catch (RuntimeException e) { + return false; + } + } + + int cachedProgramCount() { + synchronized (programs) { + return programs.size(); + } + } + + private Program programFor(IamCondition condition) { + Objects.requireNonNull(condition, "condition"); + String expression = condition.expression(); + synchronized (programs) { + Program cached = programs.get(expression); + if (cached != null) { + return cached; + } + Program compiled = compile(expression); + if (programs.size() == cacheCapacity) { + programs.remove(programs.keySet().iterator().next()); + } + programs.put(expression, compiled); + return compiled; + } + } + + private Program compile(String expression) { + try { + Env.AstIssuesTuple result = environment.compile(expression); + if (result.hasIssues() || result.getAst() == null) { + throw GcpException.invalidArgument("Unsupported IAM condition expression"); + } + Ast ast = result.getAst(); + if (ast.getResultType().getPrimitive() != Type.PrimitiveType.BOOL) { + throw GcpException.invalidArgument("IAM condition expression must evaluate to a boolean"); + } + return environment.program(ast, ProgramOption.functions(profileOverloads())); + } catch (GcpException e) { + throw e; + } catch (RuntimeException e) { + throw GcpException.invalidArgument("Unsupported IAM condition expression"); + } + } + + /** + * Declares the complete compile-time surface of the Version 1 profile. + * + *

{@link Env#newCustomEnv(EnvOption...)} intentionally omits CEL's standard library, so + * declarations are required even for familiar operators. They let CEL type-check only the + * signatures Floci supports; {@link #profileOverloads()} separately supplies the runtime + * implementations that this custom environment needs.

+ */ + private static List profileDeclarations() { + return List.of( + Decls.newFunction(Operator.LogicalAnd.id, + Decls.newOverload(Overloads.LogicalAnd, List.of(Decls.Bool, Decls.Bool), Decls.Bool)), + Decls.newFunction(Operator.LogicalOr.id, + Decls.newOverload(Overloads.LogicalOr, List.of(Decls.Bool, Decls.Bool), Decls.Bool)), + Decls.newFunction(Operator.LogicalNot.id, + Decls.newOverload(Overloads.LogicalNot, List.of(Decls.Bool), Decls.Bool)), + Decls.newFunction(Operator.Equals.id, + Decls.newOverload(Overloads.Equals, List.of(Decls.String, Decls.String), Decls.Bool)), + Decls.newFunction(Operator.NotEquals.id, + Decls.newOverload(Overloads.NotEquals, List.of(Decls.String, Decls.String), Decls.Bool)), + Decls.newFunction(Operator.Less.id, + Decls.newOverload(Overloads.LessTimestamp, + List.of(Decls.Timestamp, Decls.Timestamp), Decls.Bool)), + Decls.newFunction(Overloads.StartsWith, + Decls.newInstanceOverload(Overloads.StartsWithString, + List.of(Decls.String, Decls.String), Decls.Bool)), + Decls.newFunction(Overloads.EndsWith, + Decls.newInstanceOverload(Overloads.EndsWithString, + List.of(Decls.String, Decls.String), Decls.Bool)), + Decls.newFunction(Overloads.TypeConvertTimestamp, + Decls.newOverload(Overloads.StringToTimestamp, List.of(Decls.String), Decls.Timestamp)), + Decls.newVar("resource.service", Decls.String), + Decls.newVar("resource.type", Decls.String), + Decls.newVar("resource.name", Decls.String), + Decls.newVar("request.time", Decls.Timestamp)); + } + + /** + * Supplies the finite runtime overload set paired with the profile declarations. + * + *

Keeping this explicit prevents an operator that happens to be available through a CEL + * standard-library overload from widening the Version 1 profile at evaluation time.

+ */ + private static Overload[] profileOverloads() { + return Arrays.stream(Overload.standardOverloads()) + .filter(overload -> RUNTIME_OPERATORS.contains(overload.operator)) + .toArray(Overload[]::new); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 49440d35..4e2a006b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -74,6 +74,7 @@ floci-gcp: enabled: true iam: enabled: true + authorization-mode: disabled iamcredentials: enabled: true sts: diff --git a/src/test/java/io/floci/gcp/services/gcs/GcsGrpcControllerTest.java b/src/test/java/io/floci/gcp/services/gcs/GcsGrpcControllerTest.java index ef3603fc..4398853c 100644 --- a/src/test/java/io/floci/gcp/services/gcs/GcsGrpcControllerTest.java +++ b/src/test/java/io/floci/gcp/services/gcs/GcsGrpcControllerTest.java @@ -49,6 +49,8 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -93,6 +95,222 @@ void bucketCrudUsesCanonicalResourceNames() { assertEquals("true", updated.single().getLabelsOrThrow("updated")); } + @Test + void bucketIamConfigurationRoundTripsThroughGrpcCreate() { + RecordingObserver created = new RecordingObserver<>(); + controller.createBucket(CreateBucketRequest.newBuilder() + .setParent("projects/_") + .setBucketId("grpc-iam-config") + .setBucket(Bucket.newBuilder().setProject("projects/test-project") + .setIamConfig(Bucket.IamConfig.newBuilder() + .setUniformBucketLevelAccess(Bucket.IamConfig.UniformBucketLevelAccess.newBuilder() + .setEnabled(true) + .setLockTime(Timestamp.newBuilder().setSeconds(4_102_444_800L))) + .setPublicAccessPrevention("enforced"))) + .build(), created); + + assertNull(created.error); + assertTrue(created.single().getIamConfig().getUniformBucketLevelAccess().getEnabled()); + assertTrue(created.single().getIamConfig().getUniformBucketLevelAccess().hasLockTime()); + assertNotEquals(4_102_444_800L, created.single().getIamConfig() + .getUniformBucketLevelAccess().getLockTime().getSeconds()); + assertEquals("enforced", service.getBucket("grpc-iam-config") + .getIamConfiguration().get("publicAccessPrevention")); + } + + @Test + void invalidPublicAccessPreventionIsRejectedThroughGrpc() { + RecordingObserver created = new RecordingObserver<>(); + controller.createBucket(CreateBucketRequest.newBuilder() + .setParent("projects/_") + .setBucketId("grpc-invalid-pap") + .setBucket(Bucket.newBuilder().setProject("projects/test-project") + .setIamConfig(Bucket.IamConfig.newBuilder() + .setPublicAccessPrevention("invalid"))) + .build(), created); + + assertEquals(Status.Code.INVALID_ARGUMENT, + Status.fromThrowable(created.error).getCode()); + + service.createBucket("grpc-invalid-pap-update", "test-project", BASE_URL, Map.of()); + RecordingObserver updated = new RecordingObserver<>(); + controller.updateBucket(UpdateBucketRequest.newBuilder() + .setBucket(Bucket.newBuilder() + .setName(GcsGrpcMapper.bucketName("grpc-invalid-pap-update")) + .setIamConfig(Bucket.IamConfig.newBuilder() + .setPublicAccessPrevention("invalid"))) + .setUpdateMask(FieldMask.newBuilder() + .addPaths("iam_config.public_access_prevention")) + .build(), updated); + + assertEquals(Status.Code.INVALID_ARGUMENT, + Status.fromThrowable(updated.error).getCode()); + } + + @Test + void nestedBucketIamConfigurationUpdatesHonorFieldMask() { + String bucketName = "grpc-iam-config-update"; + service.createBucket(bucketName, "test-project", BASE_URL, Map.of( + "iamConfiguration", Map.of( + "uniformBucketLevelAccess", Map.of( + "enabled", true, + "lockedTime", "2027-01-15T08:00:00Z"), + "publicAccessPrevention", "enforced"))); + String name = GcsGrpcMapper.bucketName(bucketName); + long originalLockTime = GcsGrpcMapper.toProto(service.getBucket(bucketName)) + .getIamConfig().getUniformBucketLevelAccess().getLockTime().getSeconds(); + + RecordingObserver publicAccessUpdate = new RecordingObserver<>(); + controller.updateBucket(UpdateBucketRequest.newBuilder() + .setBucket(Bucket.newBuilder() + .setName(name) + .setIamConfig(Bucket.IamConfig.newBuilder() + .setPublicAccessPrevention("inherited"))) + .setUpdateMask(FieldMask.newBuilder() + .addPaths("iam_config.public_access_prevention")) + .build(), publicAccessUpdate); + + assertNull(publicAccessUpdate.error); + assertTrue(publicAccessUpdate.single().getIamConfig() + .getUniformBucketLevelAccess().getEnabled()); + assertTrue(publicAccessUpdate.single().getIamConfig() + .getUniformBucketLevelAccess().hasLockTime()); + + RecordingObserver lockTimeUpdate = new RecordingObserver<>(); + controller.updateBucket(UpdateBucketRequest.newBuilder() + .setBucket(Bucket.newBuilder() + .setName(name) + .setIamConfig(Bucket.IamConfig.newBuilder() + .setUniformBucketLevelAccess( + Bucket.IamConfig.UniformBucketLevelAccess.newBuilder() + .setLockTime(Timestamp.newBuilder() + .setSeconds(1_900_000_000))))) + .setUpdateMask(FieldMask.newBuilder() + .addPaths("iam_config.uniform_bucket_level_access.lock_time")) + .build(), lockTimeUpdate); + + assertNull(lockTimeUpdate.error); + assertTrue(lockTimeUpdate.single().getIamConfig() + .getUniformBucketLevelAccess().getEnabled()); + assertEquals("inherited", lockTimeUpdate.single().getIamConfig() + .getPublicAccessPrevention()); + assertEquals(originalLockTime, lockTimeUpdate.single().getIamConfig() + .getUniformBucketLevelAccess().getLockTime().getSeconds()); + + RecordingObserver enabledUpdate = new RecordingObserver<>(); + controller.updateBucket(UpdateBucketRequest.newBuilder() + .setBucket(Bucket.newBuilder() + .setName(name) + .setIamConfig(Bucket.IamConfig.newBuilder() + .setUniformBucketLevelAccess( + Bucket.IamConfig.UniformBucketLevelAccess.newBuilder() + .setEnabled(false)))) + .setUpdateMask(FieldMask.newBuilder() + .addPaths("iam_config.uniform_bucket_level_access.enabled")) + .build(), enabledUpdate); + + assertNull(enabledUpdate.error); + assertFalse(enabledUpdate.single().getIamConfig() + .getUniformBucketLevelAccess().getEnabled()); + assertFalse(enabledUpdate.single().getIamConfig() + .getUniformBucketLevelAccess().hasLockTime()); + assertEquals("inherited", enabledUpdate.single().getIamConfig() + .getPublicAccessPrevention()); + + RecordingObserver clearLockTime = new RecordingObserver<>(); + controller.updateBucket(UpdateBucketRequest.newBuilder() + .setBucket(Bucket.newBuilder() + .setName(name) + .setIamConfig(Bucket.IamConfig.newBuilder() + .setUniformBucketLevelAccess( + Bucket.IamConfig.UniformBucketLevelAccess.newBuilder()))) + .setUpdateMask(FieldMask.newBuilder() + .addPaths("iam_config.uniform_bucket_level_access.lock_time")) + .build(), clearLockTime); + + assertNull(clearLockTime.error); + assertFalse(clearLockTime.single().getIamConfig() + .getUniformBucketLevelAccess().getEnabled()); + assertFalse(clearLockTime.single().getIamConfig() + .getUniformBucketLevelAccess().hasLockTime()); + + RecordingObserver clearPublicAccess = new RecordingObserver<>(); + controller.updateBucket(UpdateBucketRequest.newBuilder() + .setBucket(Bucket.newBuilder() + .setName(name) + .setIamConfig(Bucket.IamConfig.newBuilder())) + .setUpdateMask(FieldMask.newBuilder() + .addPaths("iam_config.public_access_prevention")) + .build(), clearPublicAccess); + + assertNull(clearPublicAccess.error); + assertEquals("", clearPublicAccess.single().getIamConfig() + .getPublicAccessPrevention()); + assertEquals(Map.of( + "uniformBucketLevelAccess", Map.of("enabled", false), + "bucketPolicyOnly", Map.of("enabled", false)), + service.getBucket(bucketName).getIamConfiguration()); + } + + @Test + void iamConfigurationMessageMaskMergesIntoStoredMessage() { + assertMessageMaskDisablesUniformAccessWithoutClearingSiblingFields( + "grpc-iam-parent-mask", "iam_config"); + } + + @Test + void uniformAccessMessageMaskMergesIntoStoredMessage() { + assertMessageMaskDisablesUniformAccessWithoutClearingSiblingFields( + "grpc-ubla-parent-mask", "iam_config.uniform_bucket_level_access"); + } + + @Test + void wildcardMaskClearsOmittedIamConfiguration() { + String bucketName = "grpc-iam-wildcard-mask"; + service.createBucket(bucketName, "test-project", BASE_URL, Map.of( + "iamConfiguration", Map.of( + "uniformBucketLevelAccess", Map.of("enabled", true), + "publicAccessPrevention", "enforced"))); + + RecordingObserver updated = new RecordingObserver<>(); + controller.updateBucket(UpdateBucketRequest.newBuilder() + .setBucket(Bucket.newBuilder() + .setName(GcsGrpcMapper.bucketName(bucketName))) + .setUpdateMask(FieldMask.newBuilder().addPaths("*")) + .build(), updated); + + assertNull(updated.error); + assertFalse(updated.single().hasIamConfig()); + assertEquals(Map.of(), service.getBucket(bucketName).getIamConfiguration()); + } + + private void assertMessageMaskDisablesUniformAccessWithoutClearingSiblingFields( + String bucketName, String mask) { + service.createBucket(bucketName, "test-project", BASE_URL, Map.of( + "iamConfiguration", Map.of( + "uniformBucketLevelAccess", Map.of("enabled", true), + "publicAccessPrevention", "enforced"))); + + RecordingObserver updated = new RecordingObserver<>(); + controller.updateBucket(UpdateBucketRequest.newBuilder() + .setBucket(Bucket.newBuilder() + .setName(GcsGrpcMapper.bucketName(bucketName)) + .setIamConfig(Bucket.IamConfig.newBuilder() + .setUniformBucketLevelAccess( + Bucket.IamConfig.UniformBucketLevelAccess.newBuilder() + .setEnabled(false)))) + .setUpdateMask(FieldMask.newBuilder().addPaths(mask)) + .build(), updated); + + assertNull(updated.error); + assertFalse(updated.single().getIamConfig() + .getUniformBucketLevelAccess().getEnabled()); + assertFalse(updated.single().getIamConfig() + .getUniformBucketLevelAccess().hasLockTime()); + assertEquals("enforced", updated.single().getIamConfig() + .getPublicAccessPrevention()); + } + @Test void everyBucketResponseCarriesBucketId() { RecordingObserver created = new RecordingObserver<>(); diff --git a/src/test/java/io/floci/gcp/services/gcs/GcsServiceTest.java b/src/test/java/io/floci/gcp/services/gcs/GcsServiceTest.java index a3272620..faafc3e3 100644 --- a/src/test/java/io/floci/gcp/services/gcs/GcsServiceTest.java +++ b/src/test/java/io/floci/gcp/services/gcs/GcsServiceTest.java @@ -67,6 +67,63 @@ void createBucketStoredAndRetrievable() { assertEquals("my-bucket", bucket.getName()); } + @Test + void uniformBucketLevelAccessRoundTripsOnCreateAndPatch() { + service.createBucket("my-bucket", "p1", BASE_URL, Map.of( + "iamConfiguration", Map.of( + "uniformBucketLevelAccess", Map.of( + "enabled", true, + "lockedTime", "2100-01-01T00:00:00Z"), + "publicAccessPrevention", "enforced"))); + + Map created = service.getBucket("my-bucket").getIamConfiguration(); + assertEquals("enforced", created.get("publicAccessPrevention")); + assertEquals(created.get("uniformBucketLevelAccess"), created.get("bucketPolicyOnly")); + assertNotEquals("2100-01-01T00:00:00Z", + ((Map) created.get("uniformBucketLevelAccess")).get("lockedTime")); + + service.updateBucket("my-bucket", Map.of( + "iamConfiguration", Map.of("uniformBucketLevelAccess", Map.of("enabled", false)))); + + assertEquals(Map.of( + "uniformBucketLevelAccess", Map.of("enabled", false), + "bucketPolicyOnly", Map.of("enabled", false), + "publicAccessPrevention", "enforced"), + service.getBucket("my-bucket").getIamConfiguration()); + } + + @Test + void invalidPublicAccessPreventionDoesNotMutateBucket() { + service.createBucket("my-bucket", "p1", BASE_URL, Map.of( + "iamConfiguration", Map.of("publicAccessPrevention", "enforced"))); + + GcpException exception = assertThrows(GcpException.class, () -> service.updateBucket( + "my-bucket", + Map.of("iamConfiguration", Map.of("publicAccessPrevention", "invalid")))); + + assertEquals(400, exception.getHttpStatus()); + assertEquals("enforced", service.getBucket("my-bucket") + .getIamConfiguration().get("publicAccessPrevention")); + } + + @Test + void uniformBucketLevelAccessCannotBeDisabledAfterLockedTime() { + service.createBucket("locked-bucket", "p1", BASE_URL, Map.of()); + service.getBucket("locked-bucket").setIamConfiguration(Map.of( + "uniformBucketLevelAccess", Map.of( + "enabled", true, + "lockedTime", "2020-01-01T00:00:00Z"))); + + GcpException exception = assertThrows(GcpException.class, () -> service.updateBucket( + "locked-bucket", + Map.of("iamConfiguration", Map.of( + "uniformBucketLevelAccess", Map.of("enabled", false))))); + + assertEquals(400, exception.getHttpStatus()); + assertTrue(Boolean.TRUE.equals(((Map) service.getBucket("locked-bucket") + .getIamConfiguration().get("uniformBucketLevelAccess")).get("enabled"))); + } + @Test void timestampsUseAtMostMicrosecondPrecision() { service.createBucket("ts-bucket", "p1", BASE_URL, Map.of()); diff --git a/src/test/java/io/floci/gcp/services/gcs/GcsUniformBucketLevelAccessRestIntegrationTest.java b/src/test/java/io/floci/gcp/services/gcs/GcsUniformBucketLevelAccessRestIntegrationTest.java new file mode 100644 index 00000000..0ed89933 --- /dev/null +++ b/src/test/java/io/floci/gcp/services/gcs/GcsUniformBucketLevelAccessRestIntegrationTest.java @@ -0,0 +1,134 @@ +package io.floci.gcp.services.gcs; + +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.UUID; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@QuarkusTest +class GcsUniformBucketLevelAccessRestIntegrationTest { + + @Inject GcsService gcsService; + + @Test + void uniformBucketLevelAccessRoundTripsThroughJsonCreateAndPatch() { + String bucket = "ubla-" + UUID.randomUUID().toString().substring(0, 8); + + given() + .contentType("application/json") + .body(Map.of( + "name", bucket, + "iamConfiguration", Map.of( + "uniformBucketLevelAccess", Map.of("enabled", true, + "lockedTime", "2100-01-01T00:00:00Z"), + "publicAccessPrevention", "enforced"))) + .when().post("/storage/v1/b?project=test-project") + .then().statusCode(200) + .body("iamConfiguration.uniformBucketLevelAccess.enabled", equalTo(true)) + .body("iamConfiguration.bucketPolicyOnly.enabled", equalTo(true)); + + assertTrue(GcsGrpcMapper.toProto(gcsService.getBucket(bucket)).getIamConfig() + .getUniformBucketLevelAccess().getEnabled()); + assertTrue(GcsGrpcMapper.toProto(gcsService.getBucket(bucket)).getIamConfig() + .getUniformBucketLevelAccess().hasLockTime()); + assertNotEquals("2100-01-01T00:00:00Z", + ((Map) gcsService.getBucket(bucket).getIamConfiguration() + .get("uniformBucketLevelAccess")).get("lockedTime")); + assertEquals("enforced", GcsGrpcMapper.toProto(gcsService.getBucket(bucket)).getIamConfig() + .getPublicAccessPrevention()); + + given() + .contentType("application/json") + .body(Map.of("iamConfiguration", Map.of("uniformBucketLevelAccess", Map.of("enabled", false)))) + .when().patch("/storage/v1/b/" + bucket) + .then().statusCode(200) + .body("iamConfiguration.uniformBucketLevelAccess.enabled", equalTo(false)) + .body("iamConfiguration.bucketPolicyOnly.enabled", equalTo(false)) + .body("iamConfiguration.publicAccessPrevention", equalTo("enforced")); + + assertFalse(GcsGrpcMapper.toProto(gcsService.getBucket(bucket)).getIamConfig() + .getUniformBucketLevelAccess().getEnabled()); + assertEquals("enforced", GcsGrpcMapper.toProto(gcsService.getBucket(bucket)).getIamConfig() + .getPublicAccessPrevention()); + + given() + .when().get("/storage/v1/b/" + bucket) + .then().statusCode(200) + .body("iamConfiguration.uniformBucketLevelAccess.enabled", equalTo(false)) + .body("iamConfiguration.bucketPolicyOnly.enabled", equalTo(false)) + .body("iamConfiguration.publicAccessPrevention", equalTo("enforced")); + } + + @Test + void legacyBucketPolicyOnlyInputPopulatesBothRestFields() { + String bucket = "bucket-policy-only-" + UUID.randomUUID().toString().substring(0, 8); + + given() + .contentType("application/json") + .body(Map.of( + "name", bucket, + "iamConfiguration", Map.of( + "bucketPolicyOnly", Map.of("enabled", true)))) + .when().post("/storage/v1/b?project=test-project") + .then().statusCode(200) + .body("iamConfiguration.uniformBucketLevelAccess.enabled", equalTo(true)) + .body("iamConfiguration.bucketPolicyOnly.enabled", equalTo(true)); + + given() + .contentType("application/json") + .body(Map.of( + "iamConfiguration", Map.of( + "bucketPolicyOnly", Map.of("enabled", false)))) + .when().patch("/storage/v1/b/" + bucket) + .then().statusCode(200) + .body("iamConfiguration.uniformBucketLevelAccess.enabled", equalTo(false)) + .body("iamConfiguration.bucketPolicyOnly.enabled", equalTo(false)); + } + + @Test + void invalidPublicAccessPreventionIsRejectedOnCreateAndPatch() { + String bucket = "pap-" + UUID.randomUUID().toString().substring(0, 8); + + given() + .contentType("application/json") + .body(Map.of( + "name", bucket, + "iamConfiguration", Map.of("publicAccessPrevention", "invalid"))) + .when().post("/storage/v1/b?project=test-project") + .then().statusCode(400); + + given() + .contentType("application/json") + .body(Map.of("name", bucket)) + .when().post("/storage/v1/b?project=test-project") + .then().statusCode(200); + + given() + .contentType("application/json") + .body(Map.of("iamConfiguration", Map.of("publicAccessPrevention", "invalid"))) + .when().patch("/storage/v1/b/" + bucket) + .then().statusCode(400); + } + + @Test + void absentIamConfigurationRemainsAbsent() { + String bucket = "ubla-absent-" + UUID.randomUUID().toString().substring(0, 8); + + given() + .contentType("application/json") + .body(Map.of("name", bucket)) + .when().post("/storage/v1/b?project=test-project") + .then().statusCode(200) + .body("iamConfiguration", nullValue()); + } +} diff --git a/src/test/java/io/floci/gcp/services/iam/IamAuthorizationModeConfigTest.java b/src/test/java/io/floci/gcp/services/iam/IamAuthorizationModeConfigTest.java new file mode 100644 index 00000000..f7ae4f9b --- /dev/null +++ b/src/test/java/io/floci/gcp/services/iam/IamAuthorizationModeConfigTest.java @@ -0,0 +1,33 @@ +package io.floci.gcp.services.iam; + +import io.floci.gcp.config.EmulatorConfig; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@QuarkusTest +@TestProfile(IamAuthorizationModeConfigTest.EnforceAuthorizationProfile.class) +class IamAuthorizationModeConfigTest { + + @Inject + EmulatorConfig config; + + @Test + void acceptsEnforceAuthorizationModeWithoutChangingRequestBehavior() { + assertEquals(EmulatorConfig.IamAuthorizationMode.ENFORCE, + config.services().iam().authorizationMode()); + } + + public static class EnforceAuthorizationProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of("floci-gcp.services.iam.authorization-mode", "enforce"); + } + } +} diff --git a/src/test/java/io/floci/gcp/services/iam/IamPolicyEvaluatorTest.java b/src/test/java/io/floci/gcp/services/iam/IamPolicyEvaluatorTest.java new file mode 100644 index 00000000..aae4d579 --- /dev/null +++ b/src/test/java/io/floci/gcp/services/iam/IamPolicyEvaluatorTest.java @@ -0,0 +1,100 @@ +package io.floci.gcp.services.iam; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class IamPolicyEvaluatorTest { + + private IamPolicyEvaluator evaluator; + + @BeforeEach + void setUp() { + evaluator = new IamPolicyEvaluator(new IamRoleCatalog(), new IamResourceHierarchy(), + new NessieIamConditionEvaluator(Clock.fixed(Instant.parse("2026-07-31T00:00:00Z"), ZoneOffset.UTC), 8)); + } + + @Test + void bucketPolicyGrantsObjectViewerPermissionToMatchingServiceAccount() { + IamResource object = IamResource.gcsObject("reports", "daily.csv"); + IamPolicy policy = policy(binding("roles/storage.objectViewer", + List.of("serviceAccount:reader@example.test"), null)); + + assertTrue(evaluator.isAllowed(IamPrincipal.serviceAccount("reader@example.test"), "storage.objects.get", + object, Map.of("buckets/reports", policy))); + assertFalse(evaluator.isAllowed(IamPrincipal.serviceAccount("reader@example.test"), "storage.objects.get", + IamResource.gcsObject("other", "daily.csv"), Map.of("buckets/reports", policy))); + } + + @Test + void supportsPublicAndAuthenticatedMembersButNotUnsupportedMembers() { + IamResource object = IamResource.gcsObject("reports", "daily.csv"); + IamPolicy publicPolicy = policy(binding("roles/storage.objectViewer", List.of("allUsers"), null)); + IamPolicy authenticatedPolicy = policy(binding("roles/storage.objectViewer", + List.of("allAuthenticatedUsers"), null)); + IamPolicy groupPolicy = policy(binding("roles/storage.objectViewer", List.of("group:readers@example.test"), null)); + + assertTrue(evaluator.isAllowed(IamPrincipal.anonymous(), "storage.objects.get", object, + Map.of("buckets/reports", publicPolicy))); + assertFalse(evaluator.isAllowed(IamPrincipal.anonymous(), "storage.objects.get", object, + Map.of("buckets/reports", authenticatedPolicy))); + assertTrue(evaluator.isAllowed(IamPrincipal.serviceAccount("reader@example.test"), "storage.objects.get", object, + Map.of("buckets/reports", authenticatedPolicy))); + assertFalse(evaluator.isAllowed(IamPrincipal.serviceAccount("reader@example.test"), "storage.objects.get", object, + Map.of("buckets/reports", groupPolicy))); + } + + @Test + void falseConditionalBindingDoesNotOverrideUnconditionalGrant() { + IamResource object = IamResource.gcsObject("reports", "daily.csv"); + IamPolicy policy = policy( + binding("roles/storage.objectViewer", List.of("serviceAccount:reader@example.test"), + new IamCondition("expired", "request.time < timestamp('2020-01-01T00:00:00Z')", null)), + binding("roles/storage.objectViewer", List.of("serviceAccount:reader@example.test"), null)); + + assertTrue(evaluator.isAllowed(IamPrincipal.serviceAccount("reader@example.test"), "storage.objects.get", object, + Map.of("buckets/reports", policy))); + } + + @Test + void unknownRoleAndFalseConditionNeverGrant() { + IamResource object = IamResource.gcsObject("reports", "daily.csv"); + IamPrincipal principal = IamPrincipal.serviceAccount("reader@example.test"); + IamPolicy unknownRole = policy(binding("roles/custom.reader", List.of(principal.member()), null)); + IamPolicy falseCondition = policy(binding("roles/storage.objectViewer", List.of(principal.member()), + new IamCondition("private", "resource.name.startsWith('projects/_/buckets/reports/objects/private/')", null))); + + assertFalse(evaluator.isAllowed(principal, "storage.objects.get", object, Map.of("buckets/reports", unknownRole))); + assertFalse(evaluator.isAllowed(principal, "storage.objects.get", object, Map.of("buckets/reports", falseCondition))); + } + + @Test + void malformedConditionalPublicOrVersionOneBindingNeverGrants() { + IamResource object = IamResource.gcsObject("reports", "daily.csv"); + IamCondition trueCondition = new IamCondition("true", "true", null); + IamPolicy conditionalPublic = policy(binding("roles/storage.objectViewer", List.of("allUsers"), trueCondition)); + IamPolicy versionOneCondition = new IamPolicy(1, List.of(binding("roles/storage.objectViewer", + List.of("serviceAccount:reader@example.test"), trueCondition)), "etag"); + + assertFalse(evaluator.isAllowed(IamPrincipal.anonymous(), "storage.objects.get", object, + Map.of("buckets/reports", conditionalPublic))); + assertFalse(evaluator.isAllowed(IamPrincipal.serviceAccount("reader@example.test"), "storage.objects.get", object, + Map.of("buckets/reports", versionOneCondition))); + } + + private static IamPolicy policy(IamBinding... bindings) { + return new IamPolicy(3, List.of(bindings), "etag"); + } + + private static IamBinding binding(String role, List members, IamCondition condition) { + return new IamBinding(role, members, condition); + } +} diff --git a/src/test/java/io/floci/gcp/services/iam/IamPolicyNormalizerTest.java b/src/test/java/io/floci/gcp/services/iam/IamPolicyNormalizerTest.java new file mode 100644 index 00000000..aa7c3cfc --- /dev/null +++ b/src/test/java/io/floci/gcp/services/iam/IamPolicyNormalizerTest.java @@ -0,0 +1,111 @@ +package io.floci.gcp.services.iam; + +import io.floci.gcp.core.common.GcpException; +import io.floci.gcp.services.iam.model.StoredPolicy; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class IamPolicyNormalizerTest { + + @Test + void normalizesUnconditionalPolicyAndPreservesUnsupportedMemberForms() { + StoredPolicy policy = policy(1, List.of(Map.of( + "role", "roles/storage.objectViewer", + "members", List.of("serviceAccount:reader@example.test", "group:readers@example.test")))); + + IamPolicy normalized = IamPolicyNormalizer.normalize(policy); + + assertEquals(1, normalized.version()); + assertEquals("roles/storage.objectViewer", normalized.bindings().getFirst().role()); + assertEquals(List.of("serviceAccount:reader@example.test", "group:readers@example.test"), + normalized.bindings().getFirst().members()); + assertNull(normalized.bindings().getFirst().condition()); + } + + @Test + void treatsUnspecifiedProtoPolicyVersionAsVersionOne() { + StoredPolicy policy = policy(0, List.of(Map.of( + "role", "roles/storage.objectViewer", "members", List.of("allUsers")))); + + assertEquals(1, IamPolicyNormalizer.normalize(policy).version()); + } + + @Test + void normalizesVersionThreeConditionalBinding() { + StoredPolicy policy = policy(3, List.of(Map.of( + "role", "roles/storage.objectViewer", + "members", List.of("serviceAccount:reader@example.test"), + "condition", Map.of( + "title", "reports only", + "description", "temporary access", + "expression", "resource.name.startsWith('projects/_/buckets/reports/')")))); + + IamCondition condition = IamPolicyNormalizer.normalize(policy).bindings().getFirst().condition(); + + assertEquals("reports only", condition.title()); + assertEquals("temporary access", condition.description()); + } + + @Test + void rejectsConditionalBindingOutsideVersionThree() { + StoredPolicy policy = policy(1, List.of(Map.of( + "role", "roles/storage.objectViewer", + "members", List.of("allUsers"), + "condition", Map.of("title", "restricted", "expression", "true")))); + + assertInvalid(policy, "version 3"); + } + + @Test + void rejectsMalformedBindingFields() { + assertInvalid(policy(2, List.of()), "version"); + assertInvalid(rawPolicy(List.of("not a binding")), "binding"); + assertInvalid(policy(1, List.of(Map.of("role", "", "members", List.of("allUsers")))), "role"); + assertInvalid(policy(1, List.of(Map.of("role", "roles/storage.objectViewer", "members", List.of()))), + "members"); + assertInvalid(policy(1, List.of(Map.of( + "role", "roles/storage.objectViewer", + "members", List.of("allUsers"), + "condition", Map.of("title", "condition", "expression", "")))), "expression"); + } + + @Test + void resourceNamesRemainDistinctFromPolicyKeys() { + IamResource bucket = IamResource.gcsBucket("reports"); + IamResource object = IamResource.gcsObject("reports", "2026/july.csv"); + + assertEquals("projects/_/buckets/reports", bucket.name()); + assertEquals("projects/_/buckets/reports/objects/2026/july.csv", object.name()); + assertEquals("buckets/reports", bucket.policyResource()); + assertEquals(bucket.policyResource(), object.policyResource()); + assertNotEquals(object.name(), object.policyResource()); + } + + private static StoredPolicy policy(int version, List> bindings) { + StoredPolicy policy = new StoredPolicy(); + policy.setVersion(version); + policy.setBindings(bindings); + return policy; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static StoredPolicy rawPolicy(List bindings) { + StoredPolicy policy = new StoredPolicy(); + policy.setBindings((List) bindings); + return policy; + } + + private static void assertInvalid(StoredPolicy policy, String messagePart) { + GcpException exception = assertThrows(GcpException.class, () -> IamPolicyNormalizer.normalize(policy)); + assertEquals("INVALID_ARGUMENT", exception.getGcpStatus()); + assertTrue(exception.getMessage().contains(messagePart)); + } +} diff --git a/src/test/java/io/floci/gcp/services/iam/IamPrincipalResolverTest.java b/src/test/java/io/floci/gcp/services/iam/IamPrincipalResolverTest.java new file mode 100644 index 00000000..fa4384f0 --- /dev/null +++ b/src/test/java/io/floci/gcp/services/iam/IamPrincipalResolverTest.java @@ -0,0 +1,67 @@ +package io.floci.gcp.services.iam; + +import io.floci.gcp.core.common.GcpException; +import io.floci.gcp.core.storage.InMemoryStorage; +import io.floci.gcp.services.credentials.CredentialAccessBoundaryRule; +import io.floci.gcp.services.credentials.CredentialTokenService; +import io.floci.gcp.services.credentials.StoredCredentialToken; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class IamPrincipalResolverTest { + + private static final Instant NOW = Instant.parse("2026-08-01T00:00:00Z"); + private CredentialTokenService tokenService; + private IamPrincipalResolver resolver; + + @BeforeEach + void setUp() { + tokenService = new CredentialTokenService(new InMemoryStorage<>(), Clock.fixed(NOW, ZoneOffset.UTC)); + resolver = new IamPrincipalResolver(tokenService); + } + + @Test + void resolvesImpersonatedServiceAccountResourceNameToCanonicalMember() { + StoredCredentialToken token = tokenService.mintImpersonatedToken( + "projects/-/serviceAccounts/reader@example.test", NOW.plusSeconds(60)); + + IamPrincipalResolver.Resolution resolution = resolver.resolve("Bearer " + token.getTokenValue()); + + assertEquals("serviceAccount:reader@example.test", resolution.principal().member()); + assertFalse(resolution.downscoped()); + } + + @Test + void treatsMissingAndOrdinaryExternalTokensAsAnonymous() { + assertFalse(resolver.resolve(null).principal().isAuthenticated()); + assertFalse(resolver.resolve("Bearer external-token").principal().isAuthenticated()); + } + + @Test + void marksDownscopedTokenForCabHandlingBeforeIam() { + StoredCredentialToken token = tokenService.mintDownscopedToken("source-token", List.of( + new CredentialAccessBoundaryRule("bucket", "", List.of( + "inRole:roles/storage.objectViewer")))).token(); + + IamPrincipalResolver.Resolution resolution = resolver.resolve("Bearer " + token.getTokenValue()); + + assertTrue(resolution.downscoped()); + assertFalse(resolution.principal().isAuthenticated()); + } + + @Test + void preservesInvalidKnownFlociTokenAuthenticationError() { + assertThrows(GcpException.class, + () -> resolver.resolve("Bearer " + CredentialTokenService.IMPERSONATED_TOKEN_PREFIX + "missing")); + } +} diff --git a/src/test/java/io/floci/gcp/services/iam/NessieIamConditionEvaluatorTest.java b/src/test/java/io/floci/gcp/services/iam/NessieIamConditionEvaluatorTest.java new file mode 100644 index 00000000..e21dbbca --- /dev/null +++ b/src/test/java/io/floci/gcp/services/iam/NessieIamConditionEvaluatorTest.java @@ -0,0 +1,100 @@ +package io.floci.gcp.services.iam; + +import io.floci.gcp.core.common.GcpException; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NessieIamConditionEvaluatorTest { + + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-31T23:59:59Z"), ZoneOffset.UTC); + private static final IamResource REPORT = IamResource.gcsObject("example", "reports/july.csv"); + + @Test + void evaluatesAllowedProfileExpression() { + NessieIamConditionEvaluator evaluator = new NessieIamConditionEvaluator(CLOCK, 4); + IamCondition condition = condition("resource.service == 'storage.googleapis.com'" + + " && resource.type == 'storage.googleapis.com/Object'" + + " && resource.name.startsWith('projects/_/buckets/example/objects/reports/')" + + " && request.time < timestamp('2026-08-01T00:00:00Z')"); + + assertTrue(evaluator.matches(condition, REPORT)); + assertFalse(evaluator.matches(condition, IamResource.gcsObject("example", "private/july.csv"))); + assertFalse(evaluator.matches(condition, IamResource.gcsBucket("example"))); + } + + @Test + void supportsBooleanEqualityInequalityAndEndsWith() { + NessieIamConditionEvaluator evaluator = new NessieIamConditionEvaluator(CLOCK, 4); + IamCondition condition = condition("resource.name.endsWith('.csv')" + + " && resource.service == 'storage.googleapis.com'" + + " && resource.type != 'storage.googleapis.com/Bucket'" + + " && !(resource.name.endsWith('.tmp')" + + " || resource.name == 'projects/_/buckets/example/objects/blocked.csv')"); + + assertTrue(evaluator.matches(condition, REPORT)); + assertFalse(evaluator.matches(condition, IamResource.gcsObject("example", "reports/july.tmp"))); + assertFalse(evaluator.matches(condition, IamResource.gcsObject("example", "blocked.csv"))); + } + + @Test + void rejectsUnsupportedProfileFeatures() { + NessieIamConditionEvaluator evaluator = new NessieIamConditionEvaluator(CLOCK, 4); + + for (String expression : List.of( + "resource.name.matches('.*')", + "resource.name.extract('projects/{project}/') == 'test-project'", + "[1, 2].exists(x, x == 1)", + "request.path == '/admin'", + "unknownIdentifier == 'value'")) { + GcpException exception = assertThrows(GcpException.class, () -> evaluator.validate(condition(expression))); + assertEquals("INVALID_ARGUMENT", exception.getGcpStatus()); + } + } + + @Test + void rejectsExpressionsThatDoNotEvaluateToBoolean() { + NessieIamConditionEvaluator evaluator = new NessieIamConditionEvaluator(CLOCK, 4); + + GcpException exception = assertThrows(GcpException.class, + () -> evaluator.validate(condition("resource.name"))); + + assertEquals("INVALID_ARGUMENT", exception.getGcpStatus()); + } + + @Test + void failsClosedWhenTimestampConversionCannotProduceATimestamp() { + NessieIamConditionEvaluator evaluator = new NessieIamConditionEvaluator(CLOCK, 4); + + assertFalse(evaluator.matches(condition("request.time < timestamp('not-a-timestamp')"), REPORT)); + } + + @Test + void cachesOnlySuccessfulProgramsAndEvictsLeastRecentlyUsedEntry() { + NessieIamConditionEvaluator evaluator = new NessieIamConditionEvaluator(CLOCK, 2); + IamCondition first = condition("resource.name.endsWith('.csv')"); + IamCondition second = condition("resource.name.startsWith('projects/_/buckets/example/')"); + IamCondition third = condition("request.time < timestamp('2027-01-01T00:00:00Z')"); + + evaluator.validate(first); + evaluator.validate(second); + evaluator.validate(first); + evaluator.validate(third); + + assertEquals(2, evaluator.cachedProgramCount()); + assertFalse(evaluator.matches(condition("resource.name.matches('.*')"), REPORT)); + assertEquals(2, evaluator.cachedProgramCount()); + } + + private static IamCondition condition(String expression) { + return new IamCondition("test", expression, null); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 63771800..09ebf0bf 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -14,6 +14,9 @@ floci-gcp: storage: mode: memory services: + iam: + enabled: true + authorization-mode: disabled iamcredentials: enabled: true sts: