Skip to content
Draft
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
33 changes: 33 additions & 0 deletions .github/workflows/compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,39 @@ jobs:
-v "$(pwd)/test-results:/results" \
compat-${{ matrix.test }}

- name: Run downscoped IAM SDK test
if: matrix.test == 'sdk-test-java'
timeout-minutes: 10
run: |
docker stop floci-gcp
docker rm floci-gcp
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
docker run -d --name floci-gcp --network compat-net \
--network-alias container.localhost.floci.io \
-p 4588:4588 \
-v /var/run/docker.sock:/var/run/docker.sock \
--group-add "$DOCKER_GID" \
-e FLOCI_GCP_BASE_URL=http://floci-gcp:4588 \
-e FLOCI_GCP_HOSTNAME=floci-gcp \
-e FLOCI_GCP_SERVICES_DOCKER_NETWORK=compat-net \
-e FLOCI_GCP_SERVICES_KAFKA_MOCK=true \
-e FLOCI_GCP_SERVICES_CLOUDRUN_MOCK=false \
-e FLOCI_GCP_SERVICES_IAM_AUTHORIZATION_MODE=enforce \
-e FLOCI_GCP_SERVICES_IAM_BOOTSTRAP_ADMIN_MEMBER=allUsers \
floci-gcp:test-native
timeout 60 bash -c 'until curl -sf http://localhost:4588/health >/dev/null 2>&1; do sleep 1; done'
docker run --rm --network compat-net \
-e FLOCI_GCP_ENDPOINT=http://floci-gcp:4588 \
-e FLOCI_ENDPOINT=http://floci-gcp:4588 \
-e FLOCI_HOST=floci-gcp:4588 \
-e FLOCI_PROJECT=test-project \
-e FLOCI_GCP_CLOUDRUN_EXECUTION_ENABLED=true \
-e FLOCI_GCP_IAM_ENFORCEMENT_COMPAT=true \
-v "$(pwd)/test-results:/results" \
--entrypoint /bin/bash \
compat-sdk-test-java \
-c 'mvn test -Dtest=GcsDownscopedTokenTest#storageClientEnforcesIamPolicyForDownscopedImpersonatedToken; status=$?; mkdir -p /results; cp -r target/surefire-reports/*.xml /results/ 2>/dev/null || true; exit $status'

- name: Generate test summary
if: always() && steps.tests.outcome != 'skipped'
uses: test-summary/action@37b508cfee6d4d080eedd00b5bb240a6a784a6a5 # v2
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Both gRPC and REST are served on port **4588** via ALPN negotiation:

### Auth bypass

GCP SDKs skip credential checks when `*_EMULATOR_HOST` environment variables are set. floci-gcp does not cryptographically validate credentials: requests with no credential, external credentials, and emulator-issued OAuth or impersonated tokens are accepted. The exception is an emulator-issued downscoped token, whose GCS requests are evaluated against its Credential Access Boundary (CAB).
GCP SDKs skip credential checks when `*_EMULATOR_HOST` environment variables are set. With the default `iam.authorization-mode=disabled`, floci-gcp does not cryptographically validate credentials: requests with no credential, external credentials, and emulator-issued OAuth or impersonated tokens are accepted. Floci-issued downscoped GCS tokens are always constrained by their Credential Access Boundary (CAB); in `enforce` mode, supported GCS REST bucket and object operations apply stored bucket IAM allow policies after CAB through `GcsIamAuthorizationService`. ACLs, signed-URL identity, project policies, GCS gRPC, custom roles, groups, deny policies, and the full UBLA lifecycle remain outside that enforcement surface; see `docs/services/iam.md` for details.

### Project ID as multi-tenancy key

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export FIREBASE_AUTH_EMULATOR_HOST=localhost:4588
export GOOGLE_CLOUD_PROJECT=floci-local
```

All GCP services are available at `http://localhost:4588`. Credentials are not cryptographically validated. The exception is a Floci-issued downscoped token, whose GCS requests are evaluated against its Credential Access Boundary (CAB).
All GCP services are available at `http://localhost:4588`. Credentials are not cryptographically validated by default. Floci-issued downscoped GCS tokens are constrained by their Credential Access Boundary (CAB); setting `FLOCI_GCP_SERVICES_IAM_AUTHORIZATION_MODE=enforce` also evaluates supported GCS REST bucket and object operations against stored bucket IAM allow policies. See the [IAM service guide](docs/services/iam.md) for scope and exclusions.

<details>
<summary>Using Docker directly?</summary>
Expand Down Expand Up @@ -217,7 +217,7 @@ floci-gcp emulates GCP services across storage, messaging, identity, and managed

| Service | Protocol | Notable features |
|---|---|---|
| **Cloud Storage (GCS)** | gRPC v2 + REST XML + REST JSON | Buckets, objects, streaming and resumable upload, ranged download, object compose, ACLs, bucket IAM, conditional requests (preconditions), versioning, lifecycle, CORS, pre-signed URLs (V4), batch API, Pub/Sub object notifications, customer-supplied encryption keys (CSEK) |
| **Cloud Storage (GCS)** | gRPC v2 + REST XML + REST JSON | Buckets, objects, streaming and resumable upload, ranged download, object compose, ACLs, bucket IAM with opt-in allow-policy enforcement, conditional requests (preconditions), versioning, lifecycle, CORS, pre-signed URLs (V4), batch API, Pub/Sub object notifications, customer-supplied encryption keys (CSEK) |
| **Pub/Sub** | gRPC + REST JSON | Topics, subscriptions, publish, pull, streaming pull, push delivery, snapshots, seek, field masks on update, subscription filters (attribute filter language) |
| **Firestore** | gRPC | Documents, collections, queries (all operators), field transforms, aggregation (COUNT), transactions, batch writes, real-time listeners (`listen` stream) |
| **Datastore** | HTTP/protobuf | Entities, structured queries, GQL queries, aggregation (COUNT), transactions, GQL named/positional bindings |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@
import com.google.cloud.storage.BlobId;
import com.google.cloud.storage.BlobInfo;
import com.google.cloud.storage.BucketInfo;
import com.google.cloud.storage.StorageRoles;
import com.google.cloud.storage.Storage;
import com.google.cloud.storage.StorageException;
import com.google.cloud.Identity;
import com.google.cloud.Policy;
import com.google.cloud.iam.credentials.v1.GenerateAccessTokenResponse;
import com.google.cloud.iam.credentials.v1.IamCredentialsClient;
import com.google.protobuf.Duration;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.api.Test;

import java.net.HttpURLConnection;
Expand All @@ -26,6 +33,10 @@

class GcsDownscopedTokenTest {

private static final String CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
private static final String AUTHORIZED_SERVICE_ACCOUNT = "downscoped-reader@test-project.iam.gserviceaccount.com";
private static final String UNAUTHORIZED_SERVICE_ACCOUNT = "downscoped-other@test-project.iam.gserviceaccount.com";

@Test
void storageClientEnforcesDownscopedTokenPrefix() throws Exception {
String bucket = TestFixtures.uniqueName("downscoped-bucket");
Expand Down Expand Up @@ -86,8 +97,47 @@ void storageClientAllowsWholeBucketRuleWithoutListPrefix() throws Exception {
}
}

@Test
@EnabledIfEnvironmentVariable(named = "FLOCI_GCP_IAM_ENFORCEMENT_COMPAT", matches = "true")
void storageClientEnforcesIamPolicyForDownscopedImpersonatedToken() throws Exception {
String bucket = TestFixtures.uniqueName("downscoped-iam");
BlobId allowed = BlobId.of(bucket, "allowed/report.csv");
BlobId outside = BlobId.of(bucket, "outside/report.csv");
try (Storage setup = TestFixtures.storageClient()) {
setup.create(BucketInfo.of(bucket));
setup.create(BlobInfo.newBuilder(allowed).build(), "allowed".getBytes(StandardCharsets.UTF_8));
setup.create(BlobInfo.newBuilder(outside).build(), "outside".getBytes(StandardCharsets.UTF_8));
setup.setIamPolicy(bucket, Policy.newBuilder()
.addIdentity(StorageRoles.objectViewer(), Identity.serviceAccount(AUTHORIZED_SERVICE_ACCOUNT))
.build());
}

CredentialAccessBoundary cab = prefixReadAndListCab(bucket, "allowed/");
try (Storage scoped = TestFixtures.storageClient(
OAuth2Credentials.create(exchangeImpersonatedAccessToken(AUTHORIZED_SERVICE_ACCOUNT, cab)))) {
assertThat(new String(scoped.readAllBytes(allowed), StandardCharsets.UTF_8)).isEqualTo("allowed");
assertThat(scoped.list(bucket, Storage.BlobListOption.prefix("allowed/")).iterateAll())
.extracting(blob -> blob.getName())
.contains("allowed/report.csv");
assertThatThrownBy(() -> scoped.readAllBytes(outside))
.isInstanceOfSatisfying(StorageException.class,
exception -> assertThat(exception.getCode()).isEqualTo(403));
}

try (Storage scoped = TestFixtures.storageClient(
OAuth2Credentials.create(exchangeImpersonatedAccessToken(UNAUTHORIZED_SERVICE_ACCOUNT, cab)))) {
assertThatThrownBy(() -> scoped.readAllBytes(allowed))
.isInstanceOfSatisfying(StorageException.class,
exception -> assertThat(exception.getCode()).isEqualTo(403));
}
}

private static AccessToken downscopedAccessToken(String bucket) throws Exception {
CredentialAccessBoundary cab = CredentialAccessBoundary.newBuilder()
return exchangeAccessToken(prefixReadListAndWriteCab(bucket, "allowed/"));
}

private static CredentialAccessBoundary prefixReadListAndWriteCab(String bucket, String prefix) {
return CredentialAccessBoundary.newBuilder()
.addRule(CredentialAccessBoundary.AccessBoundaryRule.newBuilder()
.setAvailableResource("//storage.googleapis.com/projects/_/buckets/" + bucket)
.setAvailablePermissions(List.of(
Expand All @@ -96,16 +146,52 @@ private static AccessToken downscopedAccessToken(String bucket) throws Exception
"inRole:roles/storage.legacyBucketWriter"))
.setAvailabilityCondition(
CredentialAccessBoundary.AccessBoundaryRule.AvailabilityCondition.newBuilder()
.setExpression("resource.name.startsWith("
+ "'projects/_/buckets/" + bucket + "/objects/allowed/')"
+ " || api.getAttribute("
+ "'storage.googleapis.com/objectListPrefix', '').startsWith("
+ "'allowed/')")
.setExpression(prefixExpression(bucket, prefix))
.build())
.build())
.build();
}

return exchangeAccessToken(cab);
private static CredentialAccessBoundary prefixReadAndListCab(String bucket, String prefix) {
return CredentialAccessBoundary.newBuilder()
.addRule(CredentialAccessBoundary.AccessBoundaryRule.newBuilder()
.setAvailableResource("//storage.googleapis.com/projects/_/buckets/" + bucket)
.setAvailablePermissions(List.of(
"inRole:roles/storage.legacyObjectReader",
"inRole:roles/storage.objectViewer"))
.setAvailabilityCondition(
CredentialAccessBoundary.AccessBoundaryRule.AvailabilityCondition.newBuilder()
.setExpression(prefixExpression(bucket, prefix))
.build())
.build())
.build();
}

private static String prefixExpression(String bucket, String prefix) {
return "resource.name.startsWith("
+ "'projects/_/buckets/" + bucket + "/objects/" + prefix + "')"
+ " || api.getAttribute("
+ "'storage.googleapis.com/objectListPrefix', '').startsWith("
+ "'" + prefix + "')";
}

private static AccessToken exchangeImpersonatedAccessToken(
String serviceAccount, CredentialAccessBoundary cab) throws Exception {
GenerateAccessTokenResponse sourceToken;
try (IamCredentialsClient client = TestFixtures.iamCredentialsClient()) {
sourceToken = client.generateAccessToken(
"projects/-/serviceAccounts/" + serviceAccount,
List.of(), List.of(CLOUD_PLATFORM_SCOPE), Duration.newBuilder().setSeconds(600).build());
}
GoogleCredentials sourceCredentials = GoogleCredentials.create(new AccessToken(
sourceToken.getAccessToken(), Date.from(Instant.ofEpochSecond(
sourceToken.getExpireTime().getSeconds(), sourceToken.getExpireTime().getNanos()))));
DownscopedCredentials credentials = DownscopedCredentials.newBuilder()
.setSourceCredential(sourceCredentials)
.setCredentialAccessBoundary(cab)
.setHttpTransportFactory(stsTransportFactory())
.build();
return credentials.refreshAccessToken();
}

private static AccessToken exchangeAccessToken(CredentialAccessBoundary cab) throws Exception {
Expand Down
5 changes: 5 additions & 0 deletions docs/configuration/advanced/application-yml.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,18 @@ floci-gcp:
enabled: false
iam:
enabled: false
authorization-mode: disabled # disabled | enforce
# bootstrap-admin-member: serviceAccount:admin@example.iam.gserviceaccount.com
```

Via environment variable:

```bash
FLOCI_GCP_SERVICES_DATASTORE_ENABLED=false
FLOCI_GCP_SERVICES_IAM_ENABLED=false
FLOCI_GCP_SERVICES_IAM_AUTHORIZATION_MODE=disabled
# Optional: grant the named member roles/storage.admin on newly created buckets.
# FLOCI_GCP_SERVICES_IAM_BOOTSTRAP_ADMIN_MEMBER=serviceAccount:admin@example.iam.gserviceaccount.com
```

## Logging
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ 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` applies supported bucket/object IAM checks and filters bucket `testIamPermissions` |
| `FLOCI_GCP_SERVICES_IAM_BOOTSTRAP_ADMIN_MEMBER` | unset | Optional IAM member granted `roles/storage.admin` on each newly created bucket |
| `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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/gcp-setup.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# GCP CLI & SDK Setup

floci-gcp does not require real GCP credentials, and GCP SDKs automatically skip credential validation when `*_EMULATOR_HOST` environment variables are set. Requests with no credential, external credentials, and Floci-issued OAuth or impersonated tokens are accepted. The exception is a Floci-issued downscoped token, whose GCS requests are evaluated against its Credential Access Boundary (CAB).
floci-gcp does not require real GCP credentials, and GCP SDKs automatically skip credential validation when `*_EMULATOR_HOST` environment variables are set. With the default IAM authorization mode, requests with no credential, external credentials, and Floci-issued OAuth or impersonated tokens are accepted. Floci-issued downscoped GCS tokens are constrained by their Credential Access Boundary (CAB); in enforce mode, supported GCS REST bucket and object operations also evaluate stored bucket IAM allow policies. See the [IAM service guide](../services/iam.md) for scope and exclusions.

## Environment Variables

Expand Down
9 changes: 9 additions & 0 deletions docs/services/gcs.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,17 @@ Object names containing `/`, spaces, `+`, or percent-encoded sequences round-tri
**Object ACLs (REST JSON):**

- `ListObjectAcl` / `CreateObjectAcl`

- `GetObjectAcl` / `UpdateObjectAcl` / `DeleteObjectAcl`

## IAM allow-policy enforcement

Set `FLOCI_GCP_SERVICES_IAM_AUTHORIZATION_MODE=enforce` to evaluate supported
bucket IAM allow policies for bucket and object operations. The default remains
`disabled`, preserving the emulator's no-auth behavior. See the [IAM service](iam.md)
for supported principals, roles, conditions, bootstrap administration, and
intentional exclusions.

**Conditional requests (preconditions):**

- `ifGenerationMatch` / `ifGenerationNotMatch`
Expand Down
50 changes: 50 additions & 0 deletions docs/services/iam.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,56 @@ 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 no-auth behavior; `enforce` evaluates supported GCS REST bucket and object operations |
| `FLOCI_GCP_SERVICES_IAM_BOOTSTRAP_ADMIN_MEMBER` | unset | Optional IAM member granted `roles/storage.admin` on each newly created bucket |

`authorization-mode` defaults to `disabled`. IAM policy storage and policy-shaped
responses remain available in that mode, but they do not restrict requests. In
`enforce` mode, bucket `testIamPermissions` returns only permissions granted
by the stored bucket policy. Bucket metadata, bucket IAM-policy, retention-lock,
storage-layout, and notification operations are also checked against their
documented bucket permissions. JSON/XML object reads, writes, updates, deletes,
listing, compose, copy, rewrite, move, restore, and resumable uploads are checked
against the documented object permissions. Restore requires `storage.objects.restore`
and `storage.objects.create`, plus `storage.objects.delete` when it replaces a live
object. ACL operations are not restricted by IAM allow policies.

The initial role catalog supports `roles/storage.objectViewer`,
`roles/storage.objectCreator`, `roles/storage.objectAdmin`, and
`roles/storage.admin` for the explicitly enforced permissions. Bucket policies
inherit to objects. Conditions support `resource.name` equality,
`startsWith`, `endsWith`, and timestamp comparisons; regex, `extract`,
macros, and undeclared attributes are rejected.

Object-list conditions authorize the bucket-level `storage.objects.list`
permission but do not filter returned objects. ACLs, signed-URL identity,
project policies, deny policies, custom roles, groups, and the full UBLA
lifecycle remain outside this evaluator. Conditional bindings require UBLA, and a
bucket update cannot disable, remove, or partially clear UBLA while conditional
bindings remain configured.

For a downscoped token derived from a Floci-issued IAM Credentials impersonated
token, Floci preserves the source service-account identity. Object requests
first satisfy the token's Credential Access Boundary (CAB), then satisfy the
bucket policy for that service account. Consequently, a bucket policy cannot
extend a CAB grant, and a CAB cannot extend a bucket-policy grant. A downscoped
token from an external source credential has no named IAM identity and, in
`enforce` mode, can match only an `allUsers` binding.

## Enforcement bootstrap

When Floci can resolve a new bucket's caller from a valid Floci-issued
impersonated token, it persists a bucket-level `roles/storage.admin` binding for
that service-account member. This lets the creator manage the bucket after the
emulator is started with `authorization-mode: enforce`.

For callers without a resolvable identity, set
`FLOCI_GCP_SERVICES_IAM_BOOTSTRAP_ADMIN_MEMBER` to an IAM member such as
`serviceAccount:admin@example.iam.gserviceaccount.com`. That member receives
`roles/storage.admin` on every subsequently created bucket. Treat this setting
as an administrator credential: it is deliberately powerful, and an `allUsers`
value makes every new bucket publicly manageable. The setting accepts only
`serviceAccount:` members, `allAuthenticatedUsers`, or `allUsers`.

## Quick Start

Expand Down
2 changes: 1 addition & 1 deletion docs/services/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ gcloud config set project floci-local

## Auth Bypass

floci-gcp does not cryptographically validate credentials. Requests with no credential, external credentials, and Floci-issued OAuth or impersonated tokens are accepted. The exception is a Floci-issued downscoped token, whose GCS requests are evaluated against its Credential Access Boundary (CAB). This otherwise matches the behavior of GCP official emulators when `*_EMULATOR_HOST` is set.
With the default IAM authorization mode, floci-gcp does not cryptographically validate credentials. Requests with no credential, external credentials, and Floci-issued OAuth or impersonated tokens are accepted. Floci-issued downscoped GCS tokens are constrained by their Credential Access Boundary (CAB); in enforce mode, supported GCS REST bucket and object operations also evaluate stored bucket IAM allow policies. This otherwise matches the behavior of GCP official emulators when `*_EMULATOR_HOST` is set. See the [IAM service guide](iam.md) for scope and exclusions.

## Multi-Project Isolation

Expand Down
Loading