From 9ab80e8e0d40cd020e24df79117ccc62778ea26c Mon Sep 17 00:00:00 2001 From: niksis02 Date: Mon, 24 Aug 2026 17:29:29 +0400 Subject: [PATCH] feat: add standalone IAM support in WebGUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gates bucket listing behind an identity policy, lets browsers reach the standalone IAM API, and turns the WebUI into a dashboard for S3, IAM, or both. **Bucket listing.** `ListBuckets` is now gated by the new `s3:ListAllMyBuckets` action, evaluated against `arn:aws:s3:::*`. The request names no bucket, so only identity policies apply — there is no resource policy to combine with, which is the same shape `CreateBucket` already had, so both now share one identity-only evaluation path. Root and admin bypass it, and backends with no identity-policy layer keep listing as before since their listing is already narrowed to the caller's own buckets. The action is IAM-only and is deliberately absent from the bucket-policy action list. **Fixed bucket ownership.** The standalone IAM client has no per-user ownership to express — accounts are all plain users, cannot be enumerated, and access is decided by policy rather than ACL — so it now implements `auth.FixedBucketOwner` and every bucket is owned by root. Bucket creation stops resolving an owner, `ListBuckets` returns every bucket to every caller (what they may then do with one stays a per-request policy decision), and the admin `ChangeBucketOwner` reports method-not-supported. Other IAM backends are untouched. **IAM service CORS.** `--cors-allow-origin` now applies to the `iam` command: it answers preflights and stamps the CORS headers, mirroring back the requested method and headers rather than enumerating the SigV4 header set. Without it no browser can reach the IAM API at all, so setting `--webui` without it falls back to `*` with a warning. The chart gets `iamServer.corsAllowOrigin`. **WebUI.** New IAM pages for users, roles and OIDC providers, signing IAM/STS query-form requests directly from the browser. Navigation is capability-gated rather than role-gated: on sign-in the session probes the S3, admin and IAM endpoints independently and each page shows only what those credentials actually reach, so one build serves an IAM-only dashboard, an S3-only dashboard, and a combined one. The login page takes an optional IAM endpoint, seeded from the new `--webui-iam-gateways` (chart: `webui.iamGateways`) — never auto-detected, since the IAM service is a separate process. The WebUI can also be hosted by `versitygw iam` itself, for deployments with no S3 gateway behind it. **The admin API is ignored once an IAM endpoint is in play.** The IAM service is then the user directory and bucket ownership is fixed, which leaves the admin API no job: the session is given no admin endpoint at all, its login field is hidden, `users.html` redirects to its IAM counterpart, and every admin-only surface stays off screen. Dashboard and Buckets remain available to any S3 session in such a deployment, running on the S3 and IAM APIs alone and surfacing each denial per action instead of redirecting. Also fixes two WebUI bugs: embedded assets went out with a zero modification time and no `Cache-Control`, so browsers treated them as fresh for centuries and an upgraded gateway served new HTML against stale JS — they now revalidate against an ETag; and the login page's advanced-options section clipped its last field, since it animated to a height named in the stylesheet rather than the one it measures now. **Usage** IAM-only dashboard, served by the IAM service: versitygw iam --port :7076 --webui :8080 --cors-allow-origin http://localhost:8080/ IAM + S3, dashboard served by the IAM service — point it at the gateway with `--webui-gateways`, and let the gateway accept the dashboard's origin: versitygw iam --port :7076 --webui :8080 --webui-gateways http://localhost:7070/ --cors-allow-origin http://localhost:8080/ versitygw --port :7070 --cors-allow-origin http://localhost:8080/ posix /data IAM + S3, dashboard served by the S3 gateway — point it at the IAM service with `--webui-iam-gateways`, and let the IAM service accept the dashboard's origin: versitygw --port :7070 --webui :8080 --webui-iam-gateways http://localhost:7076/ posix /data versitygw iam --port :7076 --cors-allow-origin http://localhost:8080/ --- auth/access-control.go | 39 +- auth/access-control_test.go | 59 ++ auth/bucket_policy_actions.go | 4 + auth/fixed_bucket_owner.go | 36 + auth/iam_standalone.go | 7 + chart/README.md | 3 +- chart/templates/deployment.yaml | 4 + chart/templates/iam-deployment.yaml | 4 + chart/values.yaml | 16 + cmd/versitygw/iam.go | 10 + cmd/versitygw/main.go | 10 +- embedgw/embedgw.go | 17 + embedgw/iam.go | 166 +++- iamapi/internal/iammiddleware/cors.go | 63 ++ iamapi/internal/iammiddleware/cors_test.go | 151 ++++ iamapi/server.go | 15 + s3api/controllers/admin.go | 48 +- s3api/controllers/bucket-list.go | 22 +- s3api/controllers/bucket-put.go | 14 +- tests/integration/group-tests.go | 2 + tests/integration/s3_iam_access_control.go | 109 +++ tests/integration/s3_iam_utils.go | 1 + webui/web/assets/css/theme.css | 3 - webui/web/buckets.html | 298 ++++-- webui/web/dashboard.html | 274 ++++-- webui/web/explorer.html | 102 ++- webui/web/iam-oidc.html | 667 ++++++++++++++ webui/web/iam-roles.html | 775 ++++++++++++++++ webui/web/iam-users.html | 998 +++++++++++++++++++++ webui/web/iam.html | 384 ++++++++ webui/web/index.html | 204 ++++- webui/web/js/api.js | 782 +++++++++++++++- webui/web/js/app.js | 163 +++- webui/web/js/iam-ui.js | 641 +++++++++++++ webui/web/users.html | 32 +- webui/webserver.go | 36 +- 36 files changed, 5853 insertions(+), 306 deletions(-) create mode 100644 auth/fixed_bucket_owner.go create mode 100644 iamapi/internal/iammiddleware/cors.go create mode 100644 iamapi/internal/iammiddleware/cors_test.go create mode 100644 webui/web/iam-oidc.html create mode 100644 webui/web/iam-roles.html create mode 100644 webui/web/iam-users.html create mode 100644 webui/web/iam.html create mode 100644 webui/web/js/iam-ui.js diff --git a/auth/access-control.go b/auth/access-control.go index 1283b2f1..90f60eb0 100644 --- a/auth/access-control.go +++ b/auth/access-control.go @@ -556,11 +556,37 @@ func VerifyCreateBucketAccess(ctx fiber.Ctx, iam IAMService, isRoot bool, acc Ac return s3err.GetAPIError(s3err.ErrAccessDenied) } - resourceArn := ResourceArnPrefix + bucket + return verifyIdentityOnlyAccess(ctx, pe, acc, CreateBucketAction, bucket) +} + +// VerifyListAllMyBucketsAccess decides whether acc may list buckets. The +// request names no bucket, so only identity policies apply: an Allow for +// s3:ListAllMyBuckets on "arn:aws:s3:::*", the ARN AWS's own bucket-listing +// policy names. Backends with no identity-policy layer already narrow the +// listing to the caller's own buckets, so they need no permission of their own. +func VerifyListAllMyBucketsAccess(ctx fiber.Ctx, iam IAMService, isRoot bool, acc Account) error { + if isRoot || acc.Role == RoleAdmin { + return nil + } + + pe, hasPolicyEvaluator := iam.(PolicyEvaluator) + if !hasPolicyEvaluator { + return nil + } + + return verifyIdentityOnlyAccess(ctx, pe, acc, ListAllMyBucketsAction, "*") +} + +// verifyIdentityOnlyAccess decides one action from the caller's identity +// policies alone, for requests naming no existing bucket and therefore no +// resource-based policy. resource is the ARN part after "arn:aws:s3:::": a +// bucket name for CreateBucket, "*" for an account-level action. +func verifyIdentityOnlyAccess(ctx fiber.Ctx, pe PolicyEvaluator, acc Account, action Action, resource string) error { + resourceArn := ResourceArnPrefix + resource identity, err := identityPolicyDecisions(pe, AccessOptions{ Acc: acc, - Bucket: bucket, - Actions: []Action{CreateBucketAction}, + Bucket: resource, + Actions: []Action{action}, }, []string{""}, nil, requestConditionContext(ctx)) if err != nil { return err @@ -572,8 +598,7 @@ func VerifyCreateBucketAccess(ctx fiber.Ctx, iam IAMService, isRoot bool, acc Ac } // A session policy narrows what the session may do; there is no resource - // policy for a bucket that does not exist yet, so the two decisions - // simply intersect here. + // policy to combine with here, so the two decisions simply intersect. decision := identity.Decisions[0].Decision if identity.HasSessionPolicy { switch sd := identity.SessionDecisions[0].Decision; { @@ -586,11 +611,11 @@ func VerifyCreateBucketAccess(ctx fiber.Ctx, iam IAMService, isRoot bool, acc Ac switch decision { case policyDecisionDeny: - return s3err.GetExplicitDenyAccessErr(principal, string(CreateBucketAction), resourceArn, "an identity-based policy") + return s3err.GetExplicitDenyAccessErr(principal, string(action), resourceArn, "an identity-based policy") case policyDecisionAllow: return nil } - return s3err.GetImplicitDenyAccessErr(principal, string(CreateBucketAction), resourceArn) + return s3err.GetImplicitDenyAccessErr(principal, string(action), resourceArn) } func IsAdminOrOwner(acct Account, isRoot bool, acl ACL) error { diff --git a/auth/access-control_test.go b/auth/access-control_test.go index 248566ab..55a3bd29 100644 --- a/auth/access-control_test.go +++ b/auth/access-control_test.go @@ -642,6 +642,65 @@ func TestVerifyCreateBucketAccess_PolicyEvaluatorIgnoresUserPlus(t *testing.T) { assert.Len(t, pe.calls, 1, "EvaluatePolicy must be consulted even for a userplus account once a PolicyEvaluator is configured") } +// Root and admin always list buckets, with no iam backend consulted. +func TestVerifyListAllMyBucketsAccess_RootAndAdminBypass(t *testing.T) { + pe := newMockPolicyEvaluator(policyDecisionDeny) + + err := VerifyListAllMyBucketsAccess(testFiberCtx(t), pe, true, Account{Access: "testuser", Role: RoleUser}) + assert.NoError(t, err) + + err = VerifyListAllMyBucketsAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleAdmin}) + assert.NoError(t, err) + + assert.Empty(t, pe.calls, "root/admin bypass before any policy evaluation") +} + +// Backends without an identity-policy layer keep listing buckets as before: +// the listing is already narrowed to the caller's own buckets. +func TestVerifyListAllMyBucketsAccess_NoPolicyEvaluatorIsUnrestricted(t *testing.T) { + err := VerifyListAllMyBucketsAccess(testFiberCtx(t), NewIAMServiceSingle(Account{}), false, Account{Access: "testuser", Role: RoleUser}) + + assert.NoError(t, err) +} + +// A policy granting s3:ListAllMyBuckets allows the listing, evaluated +// against "arn:aws:s3:::*". +func TestVerifyListAllMyBucketsAccess_PolicyEvaluatorAllow(t *testing.T) { + pe := newMockPolicyEvaluator(policyDecisionAllow) + + err := VerifyListAllMyBucketsAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUser}) + + assert.NoError(t, err) + assert.Len(t, pe.calls, 1) + assert.Equal(t, "testuser", pe.calls[0].access) + assert.Equal(t, []string{"arn:aws:s3:::*"}, pe.calls[0].resources) + assert.Equal(t, []Action{ListAllMyBucketsAction}, pe.calls[0].actions) +} + +// No matching policy denies with the AWS-shaped implicit-deny message. +func TestVerifyListAllMyBucketsAccess_PolicyEvaluatorNoMatchDenies(t *testing.T) { + pe := newMockPolicyEvaluator(policyDecisionNoMatch) + pe.principalArn = "arn:aws:iam::000000000000:user/testuser" + + err := VerifyListAllMyBucketsAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUser}) + + apiErr := requireAccessDeniedAPIError(t, err) + assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser") + assert.Contains(t, apiErr.Description, "because no identity-based policy allows the s3:ListAllMyBuckets action") +} + +// An explicit Deny is reported with the AWS-shaped explicit-deny message. +func TestVerifyListAllMyBucketsAccess_PolicyEvaluatorExplicitDenyWins(t *testing.T) { + pe := newMockPolicyEvaluator(policyDecisionDeny) + pe.principalArn = "arn:aws:iam::000000000000:user/testuser" + + err := VerifyListAllMyBucketsAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUser}) + + apiErr := requireAccessDeniedAPIError(t, err) + assert.Contains(t, apiErr.Description, "s3:ListAllMyBuckets") + assert.Contains(t, apiErr.Description, "with an explicit deny in an identity-based policy") +} + // noObjectLockBackend answers "no lock configuration" for // GetObjectLockConfiguration, so VerifyObjectsAccess's lock check is a no-op // and only the policy/ACL half of the result is under test — matching what diff --git a/auth/bucket_policy_actions.go b/auth/bucket_policy_actions.go index 34bb54d2..11b8af84 100644 --- a/auth/bucket_policy_actions.go +++ b/auth/bucket_policy_actions.go @@ -94,6 +94,10 @@ const ( DeleteBucketWebsiteAction Action = "s3:DeleteBucketWebsite" GetBucketPolicyStatusAction Action = "s3:GetBucketPolicyStatus" GetBucketLocationAction Action = "s3:GetBucketLocation" + // s3:ListAllMyBuckets may appear only in iam user/role + // policies, so it doesn't appear in supportedActionList and + // it can never be used in bucket policy documents + ListAllMyBucketsAction Action = "s3:ListAllMyBuckets" AllActions Action = "s3:*" ) diff --git a/auth/fixed_bucket_owner.go b/auth/fixed_bucket_owner.go new file mode 100644 index 00000000..d6e233a6 --- /dev/null +++ b/auth/fixed_bucket_owner.go @@ -0,0 +1,36 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +// FixedBucketOwner is implemented by IAM backends that give every bucket the +// same owner instead of the account that created it — currently only the +// standalone IAM service client, which has no per-user ownership to express: +// every account is a plain RoleUser, they cannot be enumerated, and access is +// decided by IAM policy rather than by ACL. +// +// Backends that do not implement it keep per-creator ownership as before. +type FixedBucketOwner interface { + BucketOwner() Account +} + +// ResolveFixedBucketOwner reports the account that owns every bucket when iam +// fixes ownership, and false when ownership follows the creator instead. +func ResolveFixedBucketOwner(iam IAMService) (Account, bool) { + fbo, ok := iam.(FixedBucketOwner) + if !ok { + return Account{}, false + } + + return fbo.BucketOwner(), true +} diff --git a/auth/iam_standalone.go b/auth/iam_standalone.go index 62245b82..8fa19fba 100644 --- a/auth/iam_standalone.go +++ b/auth/iam_standalone.go @@ -118,6 +118,7 @@ var ( _ IAMService = (*IAMServiceStandalone)(nil) _ SigningKeyProvider = (*IAMServiceStandalone)(nil) _ PolicyEvaluator = (*IAMServiceStandalone)(nil) + _ FixedBucketOwner = (*IAMServiceStandalone)(nil) ) // NewIAMServiceStandalone constructs the standalone IAM service client. @@ -637,6 +638,12 @@ func (s *IAMServiceStandalone) ResolveAccounts(accessKeyIDs []string) ([]string, return missing, nil } +// BucketOwner implements FixedBucketOwner: every bucket is owned by the +// gateway's root account, the only account this process knows locally. +func (s *IAMServiceStandalone) BucketOwner() Account { + return s.rootAcc +} + // CreateAccount is not supported func (s *IAMServiceStandalone) CreateAccount(Account) error { return s3err.GetAPIError(s3err.ErrAdminMethodNotSupported) diff --git a/chart/README.md b/chart/README.md index 2ef15225..7693ccfe 100644 --- a/chart/README.md +++ b/chart/README.md @@ -102,7 +102,7 @@ gateway: | **Ingress** | `ingress.enabled=true`, `ingress.className`, `ingress.hosts`, `ingress.tls` | | **HTTPRoute** | `httpRoute.enabled=true` — Gateway API successor to Ingress for S3 API; also `admin.httpRoute.enabled=true` and `webui.httpRoute.enabled=true` to expose the admin API and/or WebUI | | **Admin API** | `admin.enabled=true` — exposes a separate management API on `admin.port` (default `7071`) | -| **WebUI** | `webui.enabled=true` — browser-based management UI on `webui.port` (default `8080`); set `webui.apiGateways` and `webui.adminGateways` to your externally reachable endpoints | +| **WebUI** | `webui.enabled=true` — browser-based management UI on `webui.port` (default `8080`); set `webui.apiGateways` and `webui.adminGateways` to your externally reachable endpoints, and `webui.iamGateways` when `iam.type=standalone` so the login page offers the IAM service (the WebUI then ignores the admin API entirely — the IAM service manages users, and buckets are managed over the S3 API) | | **Website Hosting** | `website.enabled=true` — static website hosting endpoint on `website.port` (default `8090`); optionally set `website.domain` for virtual-host routing (e.g. `example.com`), or omit it for catch-all mode where the full hostname is the bucket name | | **IAM** | `iam.enabled=true` — identity and access management. `iam.type=internal` (default) stores accounts in a flat file alongside backend data; `iam.type=standalone` delegates to a separate standalone IAM API service — see [Standalone IAM Service](#standalone-iam-service) below | | **Persistence** | `persistence.enabled=true` — provisions a PVC for backend data and IAM storage; defaults to `10Gi`, or uses a hostPath volume specified by `persistence.hostPath` | @@ -146,6 +146,7 @@ Key points: - **Private mTLS endpoint**: gateways reach the standalone IAM service over a private endpoint (`iamServer.private.port`, default `7443`) that always requires mutual TLS on TCP. Provide certificates either via `existingSecret` (bring your own `tls.crt`/`tls.key`/`ca.crt`) or `certificate.create=true` to auto-provision via cert-manager. - **Shared CA requirement**: when using cert-manager auto-provisioning, `iamServer.private.certificate.issuerRef` and `iam.standalone.certificate.issuerRef` **must reference the same CA-type issuer** (an `Issuer`/`ClusterIssuer` of kind `CA`, or a Vault issuer) — one that populates `ca.crt` in the resulting Secret. Both sides verify their peer using their own certificate's `ca.crt`, which only works when both certificates share the same issuing CA. - **External IAM service**: to point a gateway at a standalone IAM service deployed outside this chart (or by a separate chart release), set `iam.standalone.endpoint` to its `host:port` and provide the mTLS material via `iam.standalone.certificate.existingSecret`. +- **WebUI access**: to manage IAM users from the WebUI, set `webui.iamGateways` to the URL a browser can reach `iamServer` on, and `iamServer.corsAllowOrigin` to the WebUI's own origin. Every WebUI call to the IAM API is cross-origin, so without `corsAllowOrigin` the browser blocks it and the WebUI's IAM navigation silently never appears. - **Secret rotation**: the processes load mTLS material and environment-based credentials at startup. After a referenced Secret rotates, restart both Deployments or configure a Secret-reloader controller through `deploymentAnnotations` and `iamServer.deploymentAnnotations`. ## Scaling and Persistence diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 47dd6d3a..8d8bf006 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -195,6 +195,10 @@ spec: - name: VGW_WEBUI_ADMIN_GATEWAYS value: {{ .Values.webui.adminGateways | join "," | quote }} {{- end }} + {{- if .Values.webui.iamGateways }} + - name: VGW_WEBUI_IAM_GATEWAYS + value: {{ .Values.webui.iamGateways | join "," | quote }} + {{- end }} {{- end }} # Website Hosting {{- if .Values.website.enabled }} diff --git a/chart/templates/iam-deployment.yaml b/chart/templates/iam-deployment.yaml index 7ff671cc..6a1f9d43 100644 --- a/chart/templates/iam-deployment.yaml +++ b/chart/templates/iam-deployment.yaml @@ -115,6 +115,10 @@ spec: - name: VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH value: "true" {{- end }} + {{- if .Values.iamServer.corsAllowOrigin }} + - name: VGW_CORS_ALLOW_ORIGIN + value: {{ .Values.iamServer.corsAllowOrigin | quote }} + {{- end }} {{- if .Values.iamServer.tls.enabled }} - name: VGW_CERT value: /certs/tls.crt diff --git a/chart/values.yaml b/chart/values.yaml index 74d258e6..14d05c9c 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -246,6 +246,16 @@ webui: # Kubernetes because it uses the internal pod IP addresses. adminGateways: [] # - s3-admin.example.com + # List of standalone IAM service endpoints offered on the WebUI login page. + # Unlike the two lists above there is nothing to auto-generate: the IAM + # service is a separate process, so the gateway cannot discover its address. + # Setting this also tells the WebUI that the standalone IAM service, not the + # admin API, manages this deployment: the admin endpoint is ignored entirely + # (hidden from the login page along with the Users tab and every other + # admin-API surface), and bucket management runs over the S3 API instead. + # Point it at iamServer.service (see the iamServer section) or its ingress. + iamGateways: [] + # - https://iam.example.com # --- Ingress --- # Expose the WebUI via a Kubernetes Ingress resource. # Requires an ingress controller (e.g. nginx, traefik) to be installed in the cluster. @@ -401,6 +411,12 @@ iamServer: # instead of auto-fetching it over an outbound TLS connection to the # caller-supplied URL. Recommended for restricted/air-gapped clusters. disableOidcThumbprintAutofetch: false + # Access-Control-Allow-Origin for the control-plane API. Required before a + # browser can reach this service: the WebUI is served from another origin, so + # every call it makes is cross-origin and is blocked without this. Set it to + # the WebUI's own origin (see webui.ingress) when webui.iamGateways points + # here. Empty leaves the API usable by CLI and SDK clients only. + corsAllowOrigin: "" # Optional TLS for the public control-plane API. No cert-manager automation # here -- bring your own Secret (must contain tls.crt / tls.key). tls: diff --git a/cmd/versitygw/iam.go b/cmd/versitygw/iam.go index c1bf9783..447f8a15 100644 --- a/cmd/versitygw/iam.go +++ b/cmd/versitygw/iam.go @@ -71,6 +71,16 @@ func runIAM(ctx *cli.Context) error { VaultClientCert: ctx.String("vault-client-cert"), VaultClientCertKey: ctx.String("vault-client-cert-key"), DisableOIDCThumbprintAutoFetch: ctx.Bool("disable-oidc-thumbprint-autofetch"), + CORSAllowOrigin: corsAllowOrigin, + Region: region, + WebuiPorts: webuiPorts, + WebuiCertFile: webuiCertFile, + WebuiKeyFile: webuiKeyFile, + WebuiNoTLS: webuiNoTLS, + WebuiPathPrefix: webuiPathPrefix, + WebuiIAMGateways: webuiIAMGateways, + WebuiGateways: webuiGateways, + WebuiAdminGateways: webuiAdminGateways, Version: Version, Build: Build, BuildTime: BuildTime, diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index 21750ad2..ea716362 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -96,6 +96,7 @@ var ( webuiNoTLS bool webuiGateways []string webuiAdminGateways []string + webuiIAMGateways []string webuiPathPrefix string webuiS3Prefix string websitePorts []string @@ -164,6 +165,7 @@ documentation can be found in the GitHub wiki.`, admPorts = ctx.StringSlice("admin-port") webuiGateways = ctx.StringSlice("webui-gateways") webuiAdminGateways = ctx.StringSlice("webui-admin-gateways") + webuiIAMGateways = ctx.StringSlice("webui-iam-gateways") webuiPathPrefix = ctx.String("webui-path-prefix") websitePorts = ctx.StringSlice("website") @@ -245,6 +247,11 @@ func initFlags() []cli.Flag { Usage: "override auto-detected admin gateway URLs for WebUI (e.g. 'http://localhost:7080', 'https://admin.example.com'; can be specified multiple times)", EnvVars: []string{"VGW_WEBUI_ADMIN_GATEWAYS"}, }, + &cli.StringSliceFlag{ + Name: "webui-iam-gateways", + Usage: "standalone IAM service URLs offered to the WebUI login page (e.g. 'http://localhost:7076', 'https://iam.example.com'; can be specified multiple times). Not auto-detected from an S3 gateway: the IAM service is a separate process. Setting this also tells the WebUI that the standalone IAM service, not the admin API, manages this deployment: the admin endpoint is ignored entirely (hidden from the login page along with every admin-API surface), and the management pages run on the S3 and IAM APIs alone", + EnvVars: []string{"VGW_WEBUI_IAM_GATEWAYS"}, + }, &cli.StringFlag{ Name: "webui-path-prefix", Usage: "mount the WebUI under a path prefix (e.g. '/ui'); must be single segment path that starts with '/'", @@ -326,7 +333,7 @@ func initFlags() []cli.Flag { }, &cli.StringFlag{ Name: "cors-allow-origin", - Usage: "default CORS Access-Control-Allow-Origin value (applied when no bucket CORS configuration exists, and for admin APIs)", + Usage: "default CORS Access-Control-Allow-Origin value (applied when no bucket CORS configuration exists, for admin APIs, and for the standalone IAM API); required on the 'iam' command before a browser-based WebUI on another origin can reach it", EnvVars: []string{"VGW_CORS_ALLOW_ORIGIN"}, Destination: &corsAllowOrigin, }, @@ -1012,6 +1019,7 @@ func runGateway(ctx context.Context, be backend.Backend) error { WebuiNoTLS: webuiNoTLS, WebuiGateways: webuiGateways, WebuiAdminGateways: webuiAdminGateways, + WebuiIAMGateways: webuiIAMGateways, WebuiPathPrefix: webuiPathPrefix, WebuiS3Prefix: webuiS3Prefix, WebsitePorts: websitePorts, diff --git a/embedgw/embedgw.go b/embedgw/embedgw.go index b4100a8e..bd213427 100644 --- a/embedgw/embedgw.go +++ b/embedgw/embedgw.go @@ -454,6 +454,13 @@ type Config struct { // WebUI. By default the gateway auto-detects URLs from AdminPorts, or // reuses WebuiGateways when AdminPorts is empty. WebuiAdminGateways []string + // WebuiIAMGateways are the standalone IAM service (versitygw iam) URLs + // offered to the WebUI's optional IAM endpoint field. There is no + // auto-detected fallback, since the IAM service is a separate process; + // empty hides the IAM navigation unless the operator types an endpoint on + // the login page. Once an IAM endpoint is in play the WebUI ignores the + // admin API entirely. + WebuiIAMGateways []string // WebuiPathPrefix is the URL path prefix under which the WebUI and its // API endpoints are served (e.g. "/ui"). Must start with "/" and be a // single path segment with no trailing slash. Leave empty to serve from @@ -611,6 +618,14 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { } } + var validatedWebuiIAMGateways []string + if len(cfg.WebuiIAMGateways) > 0 { + validatedWebuiIAMGateways, err = validateGatewayURLs(cfg.WebuiIAMGateways, "WebuiIAMGateways") + if err != nil { + return err + } + } + utils.SetBucketNameValidationStrict(!cfg.DisableStrictBucketNames) var parsedSocketPerm os.FileMode @@ -808,6 +823,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { opts = append(opts, s3api.WithWebUI(cfg.WebuiS3Prefix, &webui.ServerConfig{ Gateways: s3WebGateways, AdminGateways: s3WebAdminGateways, + IAMGateways: validatedWebuiIAMGateways, Region: cfg.Region, })) } @@ -960,6 +976,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { webSrv, err = webui.NewServer(&webui.ServerConfig{ Gateways: gateways, AdminGateways: adminGateways, + IAMGateways: validatedWebuiIAMGateways, Region: cfg.Region, }, webOpts...) if err != nil { diff --git a/embedgw/iam.go b/embedgw/iam.go index d94e05bb..5d6e20ac 100644 --- a/embedgw/iam.go +++ b/embedgw/iam.go @@ -29,6 +29,7 @@ import ( "github.com/versity/versitygw/iamapi/private" "github.com/versity/versitygw/iamapi/storage" "github.com/versity/versitygw/internal/netutil" + "github.com/versity/versitygw/webui" ) const iamTitle = "VersityGW IAM API" @@ -137,6 +138,45 @@ type IAMConfig struct { // VaultClientCertKey is the PEM-encoded private key for VaultClientCert. VaultClientCertKey string + // CORSAllowOrigin is the Access-Control-Allow-Origin value the IAM API + // returns to browsers, and the switch that enables preflight handling. + // No browser can reach this API without it, so leaving it empty while + // WebuiPorts is set logs a warning and falls back to "*". + CORSAllowOrigin string + + // The Webui* fields host the WebUI from the IAM service process, for + // deployments with no S3 gateway behind it. They mirror Config's Webui* + // fields, except that here the IAM gateway URLs are the auto-detected + // ones (from Ports) and the S3/admin URLs can only come from a flag. + // + // WebuiPorts is the list of listening addresses for the WebUI server. + // Empty disables the WebUI entirely. + WebuiPorts []string + // WebuiCertFile/WebuiKeyFile are the WebUI server's TLS certificate. When + // both are empty and WebuiNoTLS is not set, the WebUI inherits + // CertFile/KeyFile. + WebuiCertFile string + WebuiKeyFile string + // WebuiNoTLS forces the WebUI to plain HTTP even when TLS is configured + // for the IAM API. + WebuiNoTLS bool + // WebuiPathPrefix mounts the WebUI under a single-segment path prefix + // (e.g. "/ui"). + WebuiPathPrefix string + // WebuiIAMGateways overrides the IAM service URLs auto-detected from + // Ports, for when the browser reaches the IAM API through a name this + // process cannot see, such as an ingress hostname. + WebuiIAMGateways []string + // WebuiGateways and WebuiAdminGateways are the S3 and admin gateway URLs + // offered on the login page. Neither is auto-detected here, so leaving + // both empty produces an IAM-only dashboard. + WebuiGateways []string + WebuiAdminGateways []string + // Region seeds the WebUI's default region selector. IAM's own signing + // region is fixed, so this only matters when WebuiGateways points the + // dashboard at an S3 gateway as well. + Region string + // SigHup is an optional channel that signals the IAM API to reload TLS // certificates. When nil, this feature is disabled. SigHup <-chan struct{} @@ -219,6 +259,92 @@ func newPrivateAPI(store storage.Storer, cfg *IAMConfig) (*privateAPIServer, err return &privateAPIServer{api: p, tlsOpts: tlsOpts, certStorage: certStorage}, nil } +// iamWebUIGateways resolves the IAM service URLs the WebUI login page offers. +// This process is the IAM service, so its own listening addresses are the +// auto-detected answer unless the operator overrode them. +func iamWebUIGateways(cfg *IAMConfig) ([]string, error) { + if len(cfg.WebuiIAMGateways) > 0 { + return validateGatewayURLs(cfg.WebuiIAMGateways, "WebuiIAMGateways") + } + + var gateways []string + for _, p := range cfg.Ports { + urls, err := buildServiceURLs(p, cfg.CertFile != "") + if err != nil { + return nil, fmt.Errorf("webui: build IAM gateway URLs: %w", err) + } + gateways = append(gateways, urls...) + } + sortGatewayURLs(gateways) + return gateways, nil +} + +// newIAMWebUI builds the WebUI server hosted by the IAM service process. It +// returns nil when no WebuiPorts are configured. +func newIAMWebUI(cfg *IAMConfig) (*webui.Server, error) { + if len(cfg.WebuiPorts) == 0 { + return nil, nil + } + + if err := validateWebUIPathPrefix("WebuiPathPrefix", cfg.WebuiPathPrefix); err != nil { + return nil, err + } + + iamGateways, err := iamWebUIGateways(cfg) + if err != nil { + return nil, err + } + gateways, err := validateGatewayURLs(cfg.WebuiGateways, "WebuiGateways") + if err != nil { + return nil, err + } + adminGateways, err := validateGatewayURLs(cfg.WebuiAdminGateways, "WebuiAdminGateways") + if err != nil { + return nil, err + } + + var webOpts []webui.Option + if !cfg.WebuiNoTLS { + webTLSCert, webTLSKey := cfg.WebuiCertFile, cfg.WebuiKeyFile + if webTLSCert == "" && webTLSKey == "" { + webTLSCert, webTLSKey = cfg.CertFile, cfg.KeyFile + } + if webTLSCert != "" || webTLSKey != "" { + if webTLSCert == "" { + return nil, fmt.Errorf("webui TLS key specified without cert file") + } + if webTLSKey == "" { + return nil, fmt.Errorf("webui TLS cert specified without key file") + } + cs := netutil.NewCertStorage() + if err := cs.SetCertificate(webTLSCert, webTLSKey); err != nil { + return nil, fmt.Errorf("tls: load certs: %v", err) + } + webOpts = append(webOpts, webui.WithTLS(cs)) + } + } + if cfg.Quiet { + webOpts = append(webOpts, webui.WithQuiet()) + } + if cfg.WebuiPathPrefix != "" { + webOpts = append(webOpts, webui.WithPathPrefix(cfg.WebuiPathPrefix)) + } + if cfg.SocketPerm != "" { + perm, err := strconv.ParseUint(cfg.SocketPerm, 8, 32) + if err != nil { + return nil, fmt.Errorf("invalid SocketPerm value %q: must be an octal integer (e.g. '0660'): %w", cfg.SocketPerm, err) + } + webOpts = append(webOpts, webui.WithSocketPerm(os.FileMode(perm))) + } + + return webui.NewServer(&webui.ServerConfig{ + Gateways: gateways, + AdminGateways: adminGateways, + IAMGateways: iamGateways, + Region: cfg.Region, + }, webOpts...) +} + var iamAPIRunning atomic.Bool // RunIAMAPI starts the VersityGW IAM API with the supplied configuration. It @@ -294,6 +420,16 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { if cfg.DisableOIDCThumbprintAutoFetch { opts = append(opts, iamapi.WithOIDCThumbprintAutoFetchDisabled()) } + corsAllowOrigin := strings.TrimSpace(cfg.CORSAllowOrigin) + if len(cfg.WebuiPorts) > 0 && corsAllowOrigin == "" { + // Every WebUI call to this API is cross-origin, so without an allowed + // origin the dashboard this process serves cannot talk to it at all. + corsAllowOrigin = "*" + fmt.Fprintf(os.Stderr, "WARNING: WebuiPorts is set but CORSAllowOrigin is not; defaulting to '*'; consider setting it to the WebUI's own origin\n") + } + if corsAllowOrigin != "" { + opts = append(opts, iamapi.WithCORSAllowOrigin(corsAllowOrigin)) + } debuglogger.SetLevel(cfg.LogLevel) if cfg.SocketPerm != "" { perm, err := strconv.ParseUint(cfg.SocketPerm, 8, 32) @@ -332,11 +468,16 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { } } + webSrv, err := newIAMWebUI(cfg) + if err != nil { + return fmt.Errorf("init webui: %w", err) + } + if !cfg.Quiet { cfg.printBanner() } - errCh := make(chan error, 2) + errCh := make(chan error, 3) go func() { errCh <- server.ServeMultiPort(cfg.Ports) }() @@ -347,6 +488,12 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { }() } + if webSrv != nil { + go func() { + errCh <- webSrv.ServeMultiPort(cfg.WebuiPorts) + }() + } + var sigHup <-chan struct{} if cfg.SigHup != nil { sigHup = cfg.SigHup @@ -394,6 +541,11 @@ Loop: fmt.Fprintf(os.Stderr, "shutdown private IAM API server: %v\n", err) } } + if webSrv != nil { + if err := webSrv.Shutdown(); err != nil { + fmt.Fprintf(os.Stderr, "shutdown webui server: %v\n", err) + } + } return saveErr } @@ -437,6 +589,18 @@ func (cfg IAMConfig) printBanner() { } } + if len(cfg.WebuiPorts) > 0 { + webuiInterfaces, _ := resolveIAMBannerInterfaces(cfg.WebuiPorts) + if len(webuiInterfaces) > 0 { + webuiTLS := !cfg.WebuiNoTLS && + (cfg.WebuiCertFile != "" || cfg.WebuiKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "") + lines = append(lines, centerText(""), leftText("Web dashboard listening on:")) + for _, u := range buildIAMBannerURLs(webuiInterfaces, webuiTLS) { + lines = append(lines, leftText(" "+u+cfg.WebuiPathPrefix)) + } + } + } + fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐") for _, line := range lines { fmt.Printf("│%-*s│\n", columnWidth-2, line) diff --git a/iamapi/internal/iammiddleware/cors.go b/iamapi/internal/iammiddleware/cors.go new file mode 100644 index 00000000..1ccce0d9 --- /dev/null +++ b/iamapi/internal/iammiddleware/cors.go @@ -0,0 +1,63 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iammiddleware + +import ( + "strings" + + "github.com/gofiber/fiber/v3" +) + +// corsMaxAge is how long a browser may reuse a preflight result, matching what +// IAM returns. Browsers clamp it to their own ceiling, so it is only a hint. +const corsMaxAge = "172800" + +// corsExposeHeaders names the non-safelisted response headers a browser is +// allowed to read. The IAM API sets exactly two. +const corsExposeHeaders = HeaderAmznRequestID + ",Date" + +// CORS answers browser preflights and stamps the CORS headers onto +// cross-origin responses, so the WebUI can call this API from its own origin. +// Register it only when the operator configured an allowed origin; left +// unregistered, the API stays usable by CLI and SDK clients but no browser. +func CORS(allowOrigin string) fiber.Handler { + return func(ctx fiber.Ctx) error { + if ctx.Get(fiber.HeaderOrigin) == "" { + // Not a browser call; IAM leaves these untouched. + return ctx.Next() + } + + ctx.Response().Header.Set(fiber.HeaderAccessControlAllowOrigin, allowOrigin) + ctx.Response().Header.Set(fiber.HeaderAccessControlExposeHeaders, corsExposeHeaders) + ctx.Response().Header.Add(fiber.HeaderVary, fiber.HeaderOrigin) + + if string(ctx.Request().Header.Method()) != fiber.MethodOptions { + return ctx.Next() + } + + // Preflight. Echo back what was asked for rather than enumerating the + // SigV4 header set, which changes with every signing detail. + if reqMethod := ctx.Get(fiber.HeaderAccessControlRequestMethod); strings.TrimSpace(reqMethod) != "" { + ctx.Response().Header.Set(fiber.HeaderAccessControlAllowMethods, reqMethod) + } + if reqHeaders := ctx.Get(fiber.HeaderAccessControlRequestHeaders); strings.TrimSpace(reqHeaders) != "" { + ctx.Response().Header.Set(fiber.HeaderAccessControlAllowHeaders, reqHeaders) + } + ctx.Response().Header.Set(fiber.HeaderAccessControlMaxAge, corsMaxAge) + + ctx.Status(fiber.StatusOK) + return nil + } +} diff --git a/iamapi/internal/iammiddleware/cors_test.go b/iamapi/internal/iammiddleware/cors_test.go new file mode 100644 index 00000000..ac8a019c --- /dev/null +++ b/iamapi/internal/iammiddleware/cors_test.go @@ -0,0 +1,151 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iammiddleware + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" +) + +const testOrigin = "https://webui.example.com" + +func TestCORSPreflightMirrorsRequest(t *testing.T) { + resp := corsRequest(t, http.MethodOptions, map[string]string{ + "Origin": "https://some-other.example", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "authorization,x-amz-date,content-type", + }) + + // IAM answers a preflight 200 with an empty body, not 204. + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if len(body) != 0 { + t.Fatalf("expected empty preflight body, got %q", body) + } + + // The configured origin is returned, not the one the browser sent. + assertHeader(t, resp, "Access-Control-Allow-Origin", testOrigin) + assertHeader(t, resp, "Access-Control-Allow-Methods", "POST") + assertHeader(t, resp, "Access-Control-Allow-Headers", "authorization,x-amz-date,content-type") + assertHeader(t, resp, "Access-Control-Expose-Headers", corsExposeHeaders) + assertHeader(t, resp, "Access-Control-Max-Age", corsMaxAge) + assertHeader(t, resp, "Vary", "Origin") +} + +// A preflight without Access-Control-Request-Method is still short-circuited, +// it just carries no allow-methods. +func TestCORSPreflightWithoutRequestMethod(t *testing.T) { + resp := corsRequest(t, http.MethodOptions, map[string]string{ + "Origin": testOrigin, + }) + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + assertHeader(t, resp, "Access-Control-Allow-Origin", testOrigin) + assertHeader(t, resp, "Access-Control-Max-Age", corsMaxAge) + assertNoHeader(t, resp, "Access-Control-Allow-Methods") + assertNoHeader(t, resp, "Access-Control-Allow-Headers") +} + +func TestCORSActualRequestOmitsPreflightHeaders(t *testing.T) { + resp := corsRequest(t, http.MethodPost, map[string]string{ + "Origin": testOrigin, + }) + + if resp.StatusCode != http.StatusTeapot { + t.Fatalf("expected the handler to run, got status %d", resp.StatusCode) + } + assertHeader(t, resp, "Access-Control-Allow-Origin", testOrigin) + assertHeader(t, resp, "Access-Control-Expose-Headers", corsExposeHeaders) + assertHeader(t, resp, "Vary", "Origin") + // Preflight-only headers must not leak onto an actual response. + assertNoHeader(t, resp, "Access-Control-Allow-Methods") + assertNoHeader(t, resp, "Access-Control-Allow-Headers") + assertNoHeader(t, resp, "Access-Control-Max-Age") +} + +// Without an Origin the request is not a browser call: no CORS headers. +func TestCORSWithoutOriginIsUntouched(t *testing.T) { + resp := corsRequest(t, http.MethodPost, nil) + + if resp.StatusCode != http.StatusTeapot { + t.Fatalf("expected the handler to run, got status %d", resp.StatusCode) + } + for _, hdr := range []string{ + "Access-Control-Allow-Origin", + "Access-Control-Expose-Headers", + "Access-Control-Max-Age", + "Vary", + } { + assertNoHeader(t, resp, hdr) + } +} + +// An OPTIONS without an Origin is not a preflight and must reach the router. +func TestCORSOptionsWithoutOriginIsRouted(t *testing.T) { + resp := corsRequest(t, http.MethodOptions, nil) + + if resp.StatusCode != http.StatusTeapot { + t.Fatalf("expected the handler to run, got status %d", resp.StatusCode) + } + assertNoHeader(t, resp, "Access-Control-Allow-Origin") +} + +func corsRequest(t *testing.T, method string, headers map[string]string) *http.Response { + t.Helper() + + app := fiber.New() + app.Use("*", CORS(testOrigin)) + app.All("/*", func(ctx fiber.Ctx) error { + return ctx.SendStatus(http.StatusTeapot) + }) + + req := httptest.NewRequest(method, "/", nil) + for key, val := range headers { + req.Header.Set(key, val) + } + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + t.Cleanup(func() { resp.Body.Close() }) + + return resp +} + +func assertHeader(t *testing.T, resp *http.Response, key, want string) { + t.Helper() + if got := resp.Header.Get(key); got != want { + t.Errorf("%s: expected %q, got %q", key, want, got) + } +} + +func assertNoHeader(t *testing.T, resp *http.Response, key string) { + t.Helper() + if got := resp.Header.Get(key); got != "" { + t.Errorf("%s: expected absent, got %q", key, got) + } +} diff --git a/iamapi/server.go b/iamapi/server.go index c89c6c2f..939e2134 100644 --- a/iamapi/server.go +++ b/iamapi/server.go @@ -19,6 +19,7 @@ import ( "net" "net/http" "os" + "strings" "time" "github.com/gofiber/fiber/v3" @@ -61,6 +62,8 @@ type IAMApiServer struct { // oidcThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's // TLS auto-fetch fallback; see WithOIDCThumbprintAutoFetchDisabled. oidcThumbprintAutoFetchDisabled bool + // corsAllowOrigin is the single origin browsers may call this API from + corsAllowOrigin string } func New(store storage.Storer, root RootCredentials, opts ...Option) (*IAMApiServer, error) { @@ -109,6 +112,10 @@ func New(store storage.Storer, root RootCredentials, opts ...Option) (*IAMApiSer })) } + if server.corsAllowOrigin != "" { + app.Use("*", iammiddleware.CORS(server.corsAllowOrigin)) + } + app.Use("*", iammiddleware.RequestIDs()) if server.health != "" { @@ -159,6 +166,14 @@ func WithSocketPerm(perm os.FileMode) Option { return func(s *IAMApiServer) { s.socketPerm = perm } } +// WithCORSAllowOrigin sets the Access-Control-Allow-Origin value returned to +// browsers, and enables preflight handling. Required for the WebUI, which +// never shares a port with the IAM API. Empty (the default) skips the CORS +// middleware, leaving the API usable by CLI and SDK clients only. +func WithCORSAllowOrigin(origin string) Option { + return func(s *IAMApiServer) { s.corsAllowOrigin = strings.TrimSpace(origin) } +} + func WithOnListen(fn func()) Option { return func(s *IAMApiServer) { s.onListen = fn } } diff --git a/s3api/controllers/admin.go b/s3api/controllers/admin.go index f627497e..abab138d 100644 --- a/s3api/controllers/admin.go +++ b/s3api/controllers/admin.go @@ -132,6 +132,14 @@ func (c AdminController) ListUsers(ctx fiber.Ctx) (*Response, error) { } func (c AdminController) ChangeBucketOwner(ctx fiber.Ctx) (*Response, error) { + // Nothing to move when the backend fixes ownership: every bucket already + // belongs to root, and there is no other account to hand it to. + if _, fixedOwner := auth.ResolveFixedBucketOwner(c.iam); fixedOwner { + return &Response{ + MetaOpts: &MetaOptions{}, + }, s3err.GetAPIError(s3err.ErrAdminMethodNotSupported) + } + owner := ctx.Query("owner") bucket := ctx.Query("bucket") @@ -164,28 +172,32 @@ func (c AdminController) ListBuckets(ctx fiber.Ctx) (*Response, error) { } func (c AdminController) CreateBucket(ctx fiber.Ctx) (*Response, error) { - owner := ctx.Get("x-vgw-owner") - if owner == "" { - return &Response{ - MetaOpts: &MetaOptions{}, - }, s3err.GetAPIError(s3err.ErrAdminEmptyBucketOwnerHeader) - } - - acc, err := c.iam.GetUserAccount(owner) - if err != nil { - if err == auth.ErrNoSuchUser { - err = s3err.GetAPIError(s3err.ErrAdminUserNotFound) + // A backend that fixes bucket ownership picks the owner itself, so there + // is no owner to name or resolve. + if _, fixedOwner := auth.ResolveFixedBucketOwner(c.iam); !fixedOwner { + owner := ctx.Get("x-vgw-owner") + if owner == "" { + return &Response{ + MetaOpts: &MetaOptions{}, + }, s3err.GetAPIError(s3err.ErrAdminEmptyBucketOwnerHeader) } - return &Response{ - MetaOpts: &MetaOptions{}, - }, err + acc, err := c.iam.GetUserAccount(owner) + if err != nil { + if err == auth.ErrNoSuchUser { + err = s3err.GetAPIError(s3err.ErrAdminUserNotFound) + } + + return &Response{ + MetaOpts: &MetaOptions{}, + }, err + } + + // store the owner access key id in context + ctx.RequestCtx().SetUserValue("bucket-owner", acc) } - // store the owner access key id in context - ctx.RequestCtx().SetUserValue("bucket-owner", acc) - - _, err = c.s3api.CreateBucket(ctx) + _, err := c.s3api.CreateBucket(ctx) if err != nil { return &Response{ MetaOpts: &MetaOptions{}, diff --git a/s3api/controllers/bucket-list.go b/s3api/controllers/bucket-list.go index edac2d63..c85f77c4 100644 --- a/s3api/controllers/bucket-list.go +++ b/s3api/controllers/bucket-list.go @@ -26,6 +26,23 @@ func (c S3ApiController) ListBuckets(ctx fiber.Ctx) (*Response, error) { prefix := ctx.Query("prefix") maxBucketsStr := ctx.Query("max-buckets") acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) + isRoot, _ := utils.ContextKeyIsRoot.Get(ctx).(bool) + + if err := auth.VerifyListAllMyBucketsAccess(ctx, c.iam, isRoot, acct); err != nil { + return &Response{ + MetaOpts: &MetaOptions{}, + }, err + } + + owner, listAll := acct.Access, acct.Role == auth.RoleAdmin + // A backend that fixes bucket ownership leaves no per-caller subset to + // narrow the listing to, so every caller lists every bucket, the way an + // AWS account's users do. What they may then do with one stays an IAM + // policy decision, made per request. + if fixedOwner, fixed := auth.ResolveFixedBucketOwner(c.iam); fixed { + owner, listAll = fixedOwner.Access, true + } + region, ok := utils.ContextKeyRegion.Get(ctx).(string) if !ok { region = defaultRegion @@ -38,10 +55,11 @@ func (c S3ApiController) ListBuckets(ctx fiber.Ctx) (*Response, error) { }, err } + // IsAdmin is the backends' "return every bucket, unfiltered" flag. res, err := c.be.ListBuckets(ctx.RequestCtx(), s3response.ListBucketsInput{ - Owner: acct.Access, - IsAdmin: acct.Role == auth.RoleAdmin, + Owner: owner, + IsAdmin: listAll, MaxBuckets: maxBuckets, ContinuationToken: cToken, Prefix: prefix, diff --git a/s3api/controllers/bucket-put.go b/s3api/controllers/bucket-put.go index def17455..9c1b1019 100644 --- a/s3api/controllers/bucket-put.go +++ b/s3api/controllers/bucket-put.go @@ -565,10 +565,18 @@ func (c S3ApiController) CreateBucket(ctx fiber.Ctx) (*Response, error) { } creator := utils.ContextKeyAccount.Get(ctx).(auth.Account) - if !utils.ContextKeyBucketOwner.IsSet(ctx) { - utils.ContextKeyBucketOwner.Set(ctx, creator) + // A backend that fixes bucket ownership picks the owner itself; otherwise + // it is whoever the admin API named, defaulting to the creator. The + // context is set either way: the storage backend reads the owner back out + // of it to chown the new bucket. + bucketOwner, fixedOwner := auth.ResolveFixedBucketOwner(c.iam) + if !fixedOwner { + bucketOwner = creator + if utils.ContextKeyBucketOwner.IsSet(ctx) { + bucketOwner = utils.ContextKeyBucketOwner.Get(ctx).(auth.Account) + } } - bucketOwner := utils.ContextKeyBucketOwner.Get(ctx).(auth.Account) + utils.ContextKeyBucketOwner.Set(ctx, bucketOwner) isRoot, _ := utils.ContextKeyIsRoot.Get(ctx).(bool) if err := auth.VerifyCreateBucketAccess(ctx, c.iam, isRoot, creator, bucket); err != nil { diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index cf7a7487..6a75e459 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1578,6 +1578,7 @@ func TestS3IAMAccessControl(ts *TestState) { ts.Run(S3IAMAccessControl_policy_combinations) ts.Run(S3IAMAccessControl_copy_object_requires_both_sides) ts.Run(S3IAMAccessControl_create_bucket) + ts.Run(S3IAMAccessControl_list_buckets) ts.Run(S3IAMAccessControl_governance_bypass_sources) ts.Run(S3IAMAccessControl_governance_without_bypass_header) ts.Run(S3IAMAccessControl_compliance_mode_not_bypassable) @@ -2001,6 +2002,7 @@ func GetIntTests() IntTests { "S3IAMAccessControl_policy_combinations": S3IAMAccessControl_policy_combinations, "S3IAMAccessControl_copy_object_requires_both_sides": S3IAMAccessControl_copy_object_requires_both_sides, "S3IAMAccessControl_create_bucket": S3IAMAccessControl_create_bucket, + "S3IAMAccessControl_list_buckets": S3IAMAccessControl_list_buckets, "S3IAMAccessControl_governance_bypass_sources": S3IAMAccessControl_governance_bypass_sources, "S3IAMAccessControl_governance_without_bypass_header": S3IAMAccessControl_governance_without_bypass_header, "S3IAMAccessControl_compliance_mode_not_bypassable": S3IAMAccessControl_compliance_mode_not_bypassable, diff --git a/tests/integration/s3_iam_access_control.go b/tests/integration/s3_iam_access_control.go index 6a725038..d9ea5f2f 100644 --- a/tests/integration/s3_iam_access_control.go +++ b/tests/integration/s3_iam_access_control.go @@ -696,6 +696,104 @@ func S3IAMAccessControl_create_bucket(s *S3Conf) error { }) } +// S3IAMAccessControl_list_buckets verifies ListBuckets is gated by +// s3:ListAllMyBuckets under the standalone IAM service: root always lists, +// an ordinary user needs an identity-policy Allow for the action. +func S3IAMAccessControl_list_buckets(s *S3Conf) error { + testName := "S3IAMAccessControl_list_buckets" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + allBuckets := "arn:aws:s3:::*" + + cases := []struct { + name string + policy string + wantErr func(user *s3IAMPrincipal) s3err.S3Error + }{ + { + name: "no policy denies", + wantErr: func(u *s3IAMPrincipal) s3err.S3Error { + return wantImplicitDeny(u.arn, actS3ListAllMyBuckets, allBuckets) + }, + }, + { + name: "another action's grant doesn't allow", + policy: policyDoc(accessStatement{Effect: "Allow", Action: actS3ListBucket, Resource: bucketArn(bucket)}), + wantErr: func(u *s3IAMPrincipal) s3err.S3Error { + return wantImplicitDeny(u.arn, actS3ListAllMyBuckets, allBuckets) + }, + }, + { + name: "explicit deny", + policy: policyDoc(accessStatement{Effect: "Deny", Action: actS3ListAllMyBuckets, Resource: "*"}), + wantErr: func(u *s3IAMPrincipal) s3err.S3Error { + return wantExplicitIdentityDeny(u.arn, actS3ListAllMyBuckets, allBuckets) + }, + }, + { + name: "grant on the bucket wildcard arn allows", + policy: policyDoc(accessStatement{Effect: "Allow", Action: actS3ListAllMyBuckets, Resource: allBuckets}), + }, + { + name: "grant on a bare wildcard resource allows", + policy: policyDoc(accessStatement{Effect: "Allow", Action: actS3ListAllMyBuckets, Resource: "*"}), + }, + { + name: "grant scoped to one bucket doesn't allow", + policy: policyDoc(accessStatement{Effect: "Allow", Action: actS3ListAllMyBuckets, Resource: bucketArn(bucket)}), + wantErr: func(u *s3IAMPrincipal) s3err.S3Error { + return wantImplicitDeny(u.arn, actS3ListAllMyBuckets, allBuckets) + }, + }, + } + + for _, tc := range cases { + if err := func() error { + policies := map[string]string{} + if tc.policy != "" { + policies["p"] = tc.policy + } + user, cleanup, err := newS3IAMUser(root, s, policies) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + out, err := user.client.ListBuckets(ctx, &s3.ListBucketsInput{}) + cancel() + + if tc.wantErr != nil { + return checkApiErr(err, tc.wantErr(user)) + } + if err != nil { + return fmt.Errorf("expected ListBuckets to be allowed: %w", err) + } + // Ownership is fixed to root here, so an allowed user sees + // every bucket, including the one this test created. + if !containsBucket(out.Buckets, bucket) { + return fmt.Errorf("expected the listing to contain %q, got %v", bucket, out.Buckets) + } + return nil + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + + // Root lists with no policy of its own, and is never subject to one. + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + out, err := s.GetClient().ListBuckets(ctx, &s3.ListBucketsInput{}) + cancel() + if err != nil { + return fmt.Errorf("root ListBuckets: %w", err) + } + if !containsBucket(out.Buckets, bucket) { + return fmt.Errorf("root: expected the listing to contain %q, got %v", bucket, out.Buckets) + } + + return nil + }) +} + // S3IAMAccessControl_governance_bypass_sources verifies s3:BypassGovernance // Retention follows the same precedence as any other action: an Allow from // either the identity policy or the bucket policy is enough on its own, and @@ -1803,3 +1901,14 @@ func S3IAMAccessControl_bucket_policy_unknown_principal_rejected(s *S3Conf) erro }) }) } + +// containsBucket reports whether buckets names bucket, so a listing can be +// asserted without depending on what else other tests left behind. +func containsBucket(buckets []types.Bucket, bucket string) bool { + for _, b := range buckets { + if b.Name != nil && *b.Name == bucket { + return true + } + } + return false +} diff --git a/tests/integration/s3_iam_utils.go b/tests/integration/s3_iam_utils.go index c5d21b10..358ed7f0 100644 --- a/tests/integration/s3_iam_utils.go +++ b/tests/integration/s3_iam_utils.go @@ -39,6 +39,7 @@ const ( actS3DeleteObjectVersion = "s3:DeleteObjectVersion" actS3ListBucket = "s3:ListBucket" actS3CreateBucket = "s3:CreateBucket" + actS3ListAllMyBuckets = "s3:ListAllMyBuckets" actS3BypassGovernance = "s3:BypassGovernanceRetention" ) diff --git a/webui/web/assets/css/theme.css b/webui/web/assets/css/theme.css index 7fb36bed..f223f656 100644 --- a/webui/web/assets/css/theme.css +++ b/webui/web/assets/css/theme.css @@ -185,9 +185,6 @@ input:checked + .toggle-slider:before { overflow: hidden; transition: max-height 0.3s ease-out; } -.advanced-options.show { - max-height: 500px; -} /* Explorer */ .drop-zone-active { background: rgba(0, 118, 205, 0.1); border-color: #0076CD; } diff --git a/webui/web/buckets.html b/webui/web/buckets.html index ab0366e3..15bab226 100644 --- a/webui/web/buckets.html +++ b/webui/web/buckets.html @@ -50,35 +50,45 @@ under the License.