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.