feat: add standalone IAM support in WebGUI

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/
This commit is contained in:
niksis02
2026-08-25 02:03:17 +04:00
parent 7b6b816df9
commit 9ab80e8e0d
36 changed files with 5853 additions and 306 deletions
+32 -7
View File
@@ -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 {
+59
View File
@@ -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
+4
View File
@@ -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:*"
)
+36
View File
@@ -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
}
+7
View File
@@ -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)
+2 -1
View File
@@ -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
+4
View File
@@ -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 }}
+4
View File
@@ -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
+16
View File
@@ -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:
+10
View File
@@ -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,
+9 -1
View File
@@ -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,
+17
View File
@@ -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 {
+165 -1
View File
@@ -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)
+63
View File
@@ -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
}
}
+151
View File
@@ -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)
}
}
+15
View File
@@ -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 }
}
+30 -18
View File
@@ -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{},
+20 -2
View File
@@ -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,
+11 -3
View File
@@ -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 {
+2
View File
@@ -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,
+109
View File
@@ -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
}
+1
View File
@@ -39,6 +39,7 @@ const (
actS3DeleteObjectVersion = "s3:DeleteObjectVersion"
actS3ListBucket = "s3:ListBucket"
actS3CreateBucket = "s3:CreateBucket"
actS3ListAllMyBuckets = "s3:ListAllMyBuckets"
actS3BypassGovernance = "s3:BypassGovernanceRetention"
)
-3
View File
@@ -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; }
+231 -67
View File
@@ -50,35 +50,45 @@ under the License.
</a>
</div>
<nav class="flex-1 py-4">
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-admin-only>
Admin
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-management-only>
Management
</div>
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
<span class="font-medium">Dashboard</span>
</a>
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only data-admin-users-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<span class="font-medium">Users</span>
</a>
<a href="buckets.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-admin-only>
<a href="buckets.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
<span class="font-medium">Buckets</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-admin-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<div class="mx-6 my-2 border-t border-white/10" data-management-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-s3-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span class="font-medium">Explorer</span>
</a>
<div class="mx-6 my-2 border-t border-white/10"></div>
<div class="mx-6 my-2 border-t border-white/10" data-s3-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-iam-only>
Identity &amp; Access
</div>
<a href="iam.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-iam-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span class="font-medium">IAM</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-iam-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
Resources
</div>
@@ -88,13 +98,13 @@ under the License.
</svg>
<span class="font-medium">Documentation</span>
</a>
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span class="font-medium">Bug Reports</span>
</a>
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
@@ -135,7 +145,7 @@ under the License.
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold text-charcoal">Buckets</h1>
<p class="text-charcoal-300 mt-1">View and manage bucket ownership</p>
<p id="buckets-subtitle" class="text-charcoal-300 mt-1">View and manage bucket ownership</p>
</div>
<button onclick="openCreateBucketDialog()" class="inline-flex items-center gap-2 px-4 py-2.5 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -152,7 +162,7 @@ under the License.
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-sm text-charcoal">
<span class="font-medium">Note:</span> Create buckets, view existing buckets, and transfer ownership between users.
<span class="font-medium">Note:</span> <span id="buckets-note">Create buckets, view existing buckets, and transfer ownership between users.</span>
</p>
</div>
</div>
@@ -200,13 +210,13 @@ under the License.
<colgroup>
<col style="width: 50%;">
<col style="width: 30%;">
<col style="width: 20%;">
<col id="bucket-actions-col" style="width: 20%;">
</colgroup>
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Bucket Name</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Owner</th>
<th class="text-right py-4 px-6 text-sm font-semibold text-charcoal">Actions</th>
<th id="bucket-col2-header" class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Owner</th>
<th id="bucket-actions-header" class="text-right py-4 px-6 text-sm font-semibold text-charcoal">Actions</th>
</tr>
</thead>
<tbody id="buckets-table-body">
@@ -300,7 +310,7 @@ under the License.
<p class="text-xs text-charcoal-300 mt-2">Bucket names must be lowercase, 3-63 characters, and can contain letters, numbers, and hyphens.</p>
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Owner <span class="text-red-500">*</span></label>
<label id="bucket-owner-label" class="block text-sm font-medium text-charcoal mb-2">Owner <span class="text-red-500">*</span></label>
<div class="relative" id="bucket-owner-container">
<input
type="text"
@@ -319,6 +329,7 @@ under the License.
<!-- Populated dynamically -->
</div>
</div>
<p id="bucket-owner-note" class="hidden text-xs text-charcoal-300 mt-2">This gateway is backed by the standalone IAM service, which owns every bucket as the root account. Access is granted through IAM policies rather than ownership.</p>
</div>
<div class="space-y-3">
<label class="flex items-start gap-3 cursor-pointer group">
@@ -351,8 +362,18 @@ under the License.
<script>
let allBuckets = [];
let allUsers = [];
// Bucket ownership is recorded as an access key ID, so these are the
// pickable owners: {value: <access key id>, label: <what to show>}.
let ownerOptions = [];
let selectedBucket = null;
/**
* Whether this deployment's buckets are managed through the S3 API with
* IAM policy decisions, rather than through the Admin API with per-user
* ownership. The standalone IAM service fixes every bucket's owner to
* root, so the page runs on the S3 API and drops the ownership UI (owner
* column, filter, transfer, owner picker) entirely.
*/
let iamManaged = false;
// ============================================
// Custom Dropdown Functions
@@ -438,64 +459,174 @@ under the License.
}
// Populate new owner dropdown
function populateNewOwnerDropdown(users, currentOwner) {
function populateNewOwnerDropdown(options, currentOwner) {
const dropdown = document.getElementById('new-owner-dropdown');
if (document.getElementById('modal-new-owner-display').dataset.freeText === 'true') return;
dropdown.innerHTML = '<div class="custom-dropdown-item" data-value="" onclick="selectNewOwner(\'\')">Select a user...</div>';
users.forEach(user => {
if (user.access !== currentOwner) {
const roleLabel = user.role ? ` (${user.role.charAt(0).toUpperCase() + user.role.slice(1)})` : '';
const displayText = `${user.access}${roleLabel}`;
dropdown.innerHTML += `<div class="custom-dropdown-item" data-value="${escapeHtml(user.access)}" onclick="selectNewOwner('${escapeHtml(user.access)}', '${escapeHtml(displayText)}')">${escapeHtml(displayText)}</div>`;
}
options.filter(o => o.value !== currentOwner).forEach(option => {
dropdown.innerHTML += `<div class="custom-dropdown-item" data-value="${escapeHtml(option.value)}" onclick="selectNewOwner('${escapeHtml(option.value)}', '${escapeHtml(option.label)}')">${escapeHtml(option.label)}</div>`;
});
}
// Populate bucket owner dropdown (for create bucket modal)
function populateBucketOwnerDropdown(users) {
function populateBucketOwnerDropdown(options) {
const dropdown = document.getElementById('bucket-owner-dropdown');
dropdown.innerHTML = '<div class="custom-dropdown-item" data-value="" onclick="selectBucketOwner(\'\', \'\'">Select owner...</div>';
users.forEach(user => {
const roleLabel = user.role ? ` (${user.role.charAt(0).toUpperCase() + user.role.slice(1)})` : '';
const displayText = `${user.access}${roleLabel}`;
dropdown.innerHTML += `<div class="custom-dropdown-item" data-value="${escapeHtml(user.access)}" onclick="selectBucketOwner('${escapeHtml(user.access)}', '${escapeHtml(displayText)}')">${escapeHtml(displayText)}</div>`;
if (document.getElementById('bucket-owner-display').dataset.freeText === 'true') return;
dropdown.innerHTML = '<div class="custom-dropdown-item" data-value="" onclick="selectBucketOwner(\'\', \'\')">Select owner...</div>';
options.forEach(option => {
dropdown.innerHTML += `<div class="custom-dropdown-item" data-value="${escapeHtml(option.value)}" onclick="selectBucketOwner('${escapeHtml(option.value)}', '${escapeHtml(option.label)}')">${escapeHtml(option.label)}</div>`;
});
}
if (!requireAdmin()) {
/**
* Clear an owner picker between openings. In free-text mode there is no
* "Select a user..." state to return to - that is a list's caption.
*/
function resetOwnerPicker(displayId, hiddenId, emptyLabel) {
const display = document.getElementById(displayId);
display.value = display.dataset.freeText === 'true' ? '' : emptyLabel;
document.getElementById(hiddenId).value = '';
}
/**
* Turn an owner picker into a plain text field, for callers who cannot
* enumerate owners (no iam:ListUsers, or a backend without list-users).
* The API only ever needs the access key ID, so the picker degrades to
* typing one rather than disabling the feature.
*/
function enableOwnerFreeText(displayId, hiddenId, dropdownId, placeholder) {
const display = document.getElementById(displayId);
const hidden = document.getElementById(hiddenId);
const dropdown = document.getElementById(dropdownId);
if (!display || display.dataset.freeText === 'true') return;
display.dataset.freeText = 'true';
display.readOnly = false;
display.value = '';
display.placeholder = placeholder;
display.removeAttribute('onclick');
display.onclick = null;
display.classList.remove('cursor-pointer');
display.addEventListener('input', () => { hidden.value = display.value.trim(); });
dropdown.classList.remove('show');
dropdown.innerHTML = '';
// The caret is the affordance for a list that no longer exists.
const caret = display.parentElement.querySelector('svg');
if (caret) caret.style.display = 'none';
}
if (!requireManagement()) {
// Redirected
} else {
iamManaged = api.hasIAM();
applyIamManagedMode();
initSidebarWithRole();
updateUserInfo();
loadData();
}
async function loadData() {
try {
// Load both users and buckets
allUsers = await api.listUsers();
await loadBuckets();
/**
* Collapse the ownership UI when buckets are IAM-managed. Filtering by
* owner, transferring one, and picking one at creation all stop meaning
* anything once the gateway fixes every bucket's owner itself.
*/
function applyIamManagedMode() {
if (!iamManaged) return;
// Populate owner filter dropdown
const uniqueOwners = [...new Set(allBuckets.map(b => b.owner).filter(Boolean))];
populateOwnerFilterDropdown(uniqueOwners);
filterBuckets();
document.getElementById('buckets-subtitle').textContent = 'Create and browse buckets';
document.getElementById('owner-filter-container').classList.add('hidden');
document.getElementById('buckets-note').textContent =
'Create buckets and view existing buckets. Access is granted through IAM policies rather than ownership.';
// The owner column shows the one thing the S3 listing does report:
// creation time. The actions column held only the ownership transfer,
// so it goes entirely.
document.getElementById('bucket-col2-header').textContent = 'Created';
document.getElementById('bucket-actions-header').remove();
document.getElementById('bucket-actions-col').remove();
// The create form states the fixed ownership instead of asking for it.
document.getElementById('bucket-owner-label').classList.add('hidden');
document.getElementById('bucket-owner-container').classList.add('hidden');
document.getElementById('bucket-owner-note').classList.remove('hidden');
}
async function loadData() {
// Buckets and the owner list are separate permissions, so they load
// independently: a caller who cannot enumerate owners can still manage
// buckets. loadBuckets renders the table itself either way - re-rendering
// here would paint "No buckets found" over the listing-denied state.
await Promise.all([
(async () => {
await loadBuckets();
if (!iamManaged) {
const uniqueOwners = [...new Set(allBuckets.map(b => b.owner).filter(Boolean))];
populateOwnerFilterDropdown(uniqueOwners);
}
})(),
loadOwnerOptions(),
]);
}
async function loadOwnerOptions() {
// Nothing to enumerate when the gateway picks the owner itself.
if (iamManaged) return;
try {
ownerOptions = await loadGatewayOwnerOptions();
} catch (error) {
console.error('Error loading data:', error);
showToast('Error loading data: ' + error.message, 'error');
console.error('Error loading owner list:', error);
ownerOptions = [];
}
if (ownerOptions.length > 0) return;
const placeholder = 'Access key ID of a user';
enableOwnerFreeText('modal-new-owner-display', 'modal-new-owner', 'new-owner-dropdown', placeholder);
enableOwnerFreeText('bucket-owner-display', 'bucket-owner', 'bucket-owner-dropdown', placeholder);
}
async function loadGatewayOwnerOptions() {
const users = await api.listUsers();
return users.map(user => ({
value: user.access,
label: user.role ? `${user.access} (${user.role.charAt(0).toUpperCase() + user.role.slice(1)})` : user.access,
}));
}
async function loadBuckets() {
showTableLoading('buckets-table-body', 4);
showTableLoading('buckets-table-body', iamManaged ? 2 : 3);
try {
allBuckets = await api.listBuckets();
allBuckets = iamManaged ? await api.listBucketsS3() : await api.listBuckets();
filterBuckets();
} catch (error) {
console.error('Error loading buckets:', error);
if (iamManaged && error.code === 'AccessDenied') {
showListBucketsDenied();
return;
}
showToast('Error loading buckets: ' + error.message, 'error');
showEmptyState('buckets-table-body', 4, 'Error loading buckets');
showEmptyState('buckets-table-body', 3, 'Error loading buckets');
}
}
/**
* s3:ListAllMyBuckets is denied, but specific buckets may still be within
* reach by name - say so instead of showing a generic failure.
*/
function showListBucketsDenied() {
document.getElementById('buckets-table-body').innerHTML = `
<tr>
<td colspan="3" class="py-12 px-6 text-center">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
<p class="text-gray-500">You don't have permission to list buckets</p>
<p class="text-sm text-charcoal-300 mt-1">A bucket you do have access to can still be opened by name from the <a href="explorer.html" class="text-accent hover:underline">Explorer</a>.</p>
</td>
</tr>
`;
}
function filterBuckets() {
const searchTerm = document.getElementById('search-input').value.toLowerCase();
const ownerFilter = document.getElementById('owner-filter').value;
@@ -518,14 +649,30 @@ under the License.
tbody.innerHTML = '';
if (buckets.length === 0) {
showEmptyState('buckets-table-body', 4, 'No buckets found');
showEmptyState('buckets-table-body', 3, 'No buckets found');
return;
}
// Transferring ownership is an Admin API action a gateway with fixed
// ownership refuses outright, so the whole column is gone on an
// IAM-managed deployment (see applyIamManagedMode).
const ownerAction = bucket => `
<button onclick="openChangeOwnerModal('${escapeHtml(bucket.name)}', '${escapeHtml(bucket.owner || '')}')" class="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-charcoal-400 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>
</svg>
Owner
</button>`;
buckets.forEach(bucket => {
const explorerHref = `explorer.html#${encodeURIComponent(bucket.name)}`;
const row = document.createElement('tr');
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
// Column two is the owner when the Admin API reports one, and the
// S3 listing's creation date when buckets are IAM-managed.
const detailCell = iamManaged
? `<span class="text-sm text-charcoal-400">${escapeHtml(formatCreationDate(bucket.creationdate))}</span>`
: `<span class="font-mono text-sm text-charcoal-400">${escapeHtml(bucket.owner || 'Unknown')}</span>`;
row.innerHTML = `
<td class="py-4 px-6">
<div class="flex items-center gap-3">
@@ -537,33 +684,30 @@ under the License.
<a href="${explorerHref}" class="font-mono text-sm text-accent hover:underline">${escapeHtml(bucket.name)}</a>
</div>
</td>
<td class="py-4 px-6">
<span class="font-mono text-sm text-charcoal-400">${escapeHtml(bucket.owner || 'Unknown')}</span>
</td>
<td class="py-4 px-6 text-right">
<div class="flex items-center justify-end gap-2">
<button onclick="openChangeOwnerModal('${escapeHtml(bucket.name)}', '${escapeHtml(bucket.owner || '')}')" class="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-charcoal-400 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>
</svg>
Owner
</button>
<td class="py-4 px-6">${detailCell}</td>
${iamManaged ? '' : `<td class="py-4 px-6 text-right">
<div class="flex items-center justify-end gap-2">${ownerAction(bucket)}
</div>
</td>
</td>`}
`;
tbody.appendChild(row);
});
}
function formatCreationDate(value) {
if (!value) return '-';
const date = new Date(value);
return isNaN(date.getTime()) ? value : date.toLocaleString();
}
function openChangeOwnerModal(bucket, currentOwner) {
selectedBucket = bucket;
document.getElementById('modal-bucket').value = bucket;
document.getElementById('modal-current-owner').value = currentOwner || 'Unknown';
// Reset and populate new owner dropdown
document.getElementById('modal-new-owner-display').value = 'Select a user...';
document.getElementById('modal-new-owner').value = '';
populateNewOwnerDropdown(allUsers, currentOwner);
resetOwnerPicker('modal-new-owner-display', 'modal-new-owner', 'Select a user...');
populateNewOwnerDropdown(ownerOptions, currentOwner);
openModal('owner-modal');
}
@@ -598,11 +742,12 @@ under the License.
function openCreateBucketDialog() {
document.getElementById('new-bucket-name').value = '';
document.getElementById('bucket-owner-display').value = 'Select owner...';
document.getElementById('bucket-owner').value = '';
if (!iamManaged) {
resetOwnerPicker('bucket-owner-display', 'bucket-owner', 'Select owner...');
populateBucketOwnerDropdown(ownerOptions);
}
document.getElementById('enable-versioning').checked = false;
document.getElementById('enable-object-lock').checked = false;
populateBucketOwnerDropdown(allUsers);
openModal('create-bucket-modal');
}
@@ -617,7 +762,7 @@ under the License.
return;
}
if (!owner) {
if (!iamManaged && !owner) {
showToast('Please select an owner', 'warning');
return;
}
@@ -642,7 +787,26 @@ under the License.
setLoading(btn, true);
try {
await api.createBucketWithOwner(bucketName, owner, enableVersioning, enableObjectLock);
if (iamManaged) {
// S3 CreateBucket: the gateway fixes the owner itself, and whether
// this caller may create at all is an IAM policy decision.
await api.createBucket(bucketName, enableObjectLock);
// Object lock enables versioning on its own; otherwise versioning
// is a separate, separately-authorized call on the new bucket.
if (enableVersioning && !enableObjectLock) {
try {
await api.putBucketVersioning(bucketName, 'Enabled');
} catch (error) {
console.warn('Failed to enable versioning after bucket creation:', error);
showToast(`Bucket "${bucketName}" created, but enabling versioning failed: ` + error.message, 'warning');
closeModal('create-bucket-modal');
await loadBuckets();
return;
}
}
} else {
await api.createBucketWithOwner(bucketName, owner, enableVersioning, enableObjectLock);
}
showToast(`Bucket "${bucketName}" created successfully`, 'success');
closeModal('create-bucket-modal');
// Reload buckets list
+209 -65
View File
@@ -29,6 +29,7 @@ under the License.
<body class="min-h-screen bg-surface">
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<script src="js/iam-ui.js"></script>
<div class="relative flex h-screen overflow-hidden">
<input id="sidebar-toggle" type="checkbox" class="peer hidden"/>
@@ -50,35 +51,45 @@ under the License.
</a>
</div>
<nav class="flex-1 py-4">
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-admin-only>
Admin
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-management-only>
Management
</div>
<a href="dashboard.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-admin-only>
<a href="dashboard.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
<span class="font-medium">Dashboard</span>
</a>
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only data-admin-users-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<span class="font-medium">Users</span>
</a>
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
<span class="font-medium">Buckets</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-admin-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<div class="mx-6 my-2 border-t border-white/10" data-management-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-s3-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span class="font-medium">Explorer</span>
</a>
<div class="mx-6 my-2 border-t border-white/10"></div>
<div class="mx-6 my-2 border-t border-white/10" data-s3-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-iam-only>
Identity &amp; Access
</div>
<a href="iam.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-iam-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span class="font-medium">IAM</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-iam-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
Resources
</div>
@@ -88,13 +99,13 @@ under the License.
</svg>
<span class="font-medium">Documentation</span>
</a>
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span class="font-medium">Bug Reports</span>
</a>
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
@@ -137,8 +148,8 @@ under the License.
<div class="max-w-7xl mx-auto">
<!-- Metric Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Total Users -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<!-- Total Users (gateway account store) -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100" data-admin-users-only>
<div class="flex items-center justify-between">
<div>
<p class="text-charcoal-300 text-sm font-medium">Total Users</p>
@@ -152,12 +163,30 @@ under the License.
</div>
</div>
<!-- IAM Users - takes the slot above when the standalone IAM
service is this deployment's user directory -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100" data-iam-only>
<div class="flex items-center justify-between">
<div>
<p class="text-charcoal-300 text-sm font-medium">IAM Users</p>
<p id="iam-user-count" class="text-3xl font-bold text-charcoal mt-2">-</p>
<p id="iam-user-count-note" class="text-xs text-charcoal-300 mt-1"></p>
</div>
<div class="w-14 h-14 bg-primary-50 rounded-xl flex items-center justify-center">
<svg class="w-7 h-7 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
</div>
</div>
</div>
<!-- Total Buckets -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100" data-s3-only>
<div class="flex items-center justify-between">
<div>
<p class="text-charcoal-300 text-sm font-medium">Total Buckets</p>
<p id="bucket-count" class="text-3xl font-bold text-charcoal mt-2">-</p>
<p id="bucket-count-note" class="text-xs text-charcoal-300 mt-1"></p>
</div>
<div class="w-14 h-14 bg-accent-50 rounded-xl flex items-center justify-center">
<svg class="w-7 h-7 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -192,7 +221,7 @@ under the License.
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<h3 class="text-lg font-semibold text-charcoal mb-4">Quick Actions</h3>
<div class="space-y-3">
<a href="users.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group">
<a href="users.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group" data-admin-users-only>
<div class="w-10 h-10 bg-primary-50 rounded-lg flex items-center justify-center group-hover:bg-primary-100 transition-colors">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/>
@@ -206,7 +235,7 @@ under the License.
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
<a href="buckets.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group">
<a href="buckets.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group" data-s3-only>
<div class="w-10 h-10 bg-accent-50 rounded-lg flex items-center justify-center group-hover:bg-accent-100 transition-colors">
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
@@ -214,7 +243,35 @@ under the License.
</div>
<div>
<p class="font-medium text-charcoal">Manage Buckets</p>
<p class="text-sm text-charcoal-300">View and manage bucket ownership</p>
<p class="text-sm text-charcoal-300">Create, view, and manage buckets</p>
</div>
<svg class="w-5 h-5 text-charcoal-300 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
<a href="iam-users.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group" data-iam-only>
<div class="w-10 h-10 bg-primary-50 rounded-lg flex items-center justify-center group-hover:bg-primary-100 transition-colors">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
</div>
<div>
<p class="font-medium text-charcoal">Manage IAM Users</p>
<p class="text-sm text-charcoal-300">IAM users, access keys, and inline policies</p>
</div>
<svg class="w-5 h-5 text-charcoal-300 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
<a href="iam-roles.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group" data-iam-only>
<div class="w-10 h-10 bg-accent-50 rounded-lg flex items-center justify-center group-hover:bg-accent-100 transition-colors">
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
</div>
<div>
<p class="font-medium text-charcoal">Manage IAM Roles</p>
<p class="text-sm text-charcoal-300">Roles, trust policies, and inline policies</p>
</div>
<svg class="w-5 h-5 text-charcoal-300 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
@@ -250,8 +307,31 @@ under the License.
</div>
</div>
<!-- Recent IAM Users Table -->
<div class="mt-6 bg-white rounded-xl p-6 shadow-sm border border-gray-100" data-iam-only>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-charcoal">Recent IAM Users</h3>
<a href="iam-users.html" class="text-accent hover:text-accent-600 text-sm font-medium">View all</a>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-100">
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">User Name</th>
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">Path</th>
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">ARN</th>
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">Created</th>
</tr>
</thead>
<tbody id="recent-iam-users">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
<!-- Recent Users Table -->
<div class="mt-6 bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div class="mt-6 bg-white rounded-xl p-6 shadow-sm border border-gray-100" data-admin-users-only>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-charcoal">Recent Users</h3>
<a href="users.html" class="text-accent hover:text-accent-600 text-sm font-medium">View all</a>
@@ -279,8 +359,9 @@ under the License.
</div>
<script>
// Auth guard - require admin role
if (!requireAdmin()) {
// Auth guard - admin session, or any S3 session in a standalone-IAM
// deployment (where this page runs on the S3 and IAM APIs alone)
if (!requireManagement()) {
// Will redirect to login or explorer
} else {
initSidebarWithRole();
@@ -288,6 +369,9 @@ under the License.
loadDashboard();
}
// Each panel is filled by its own request so one failure does not blank
// the others, which matters once the panels stop sharing a permission:
// access here is decided per action.
async function loadDashboard() {
const info = api.getCredentialsInfo();
@@ -296,56 +380,116 @@ under the License.
document.getElementById('region-display').textContent = info.region || '-';
document.getElementById('access-key-display').textContent = info.accessKey || '-';
const results = await Promise.all([
api.hasIAM() ? loadIamUsers() : loadGatewayUsers(),
loadBucketCount(),
]);
const connected = results.some(Boolean);
document.getElementById('system-status').innerHTML = connected
? `<span class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></span>
<p class="text-xl font-bold text-green-600">Connected</p>`
: `<span class="w-3 h-3 bg-red-500 rounded-full"></span>
<p class="text-xl font-bold text-red-600">Error</p>`;
}
async function loadBucketCount() {
if (!api.hasS3()) return true;
const count = document.getElementById('bucket-count');
const note = document.getElementById('bucket-count-note');
note.textContent = '';
try {
// Load users
const users = await api.listUsers();
document.getElementById('user-count').textContent = users.length;
// Load buckets
const buckets = await api.listBuckets();
document.getElementById('bucket-count').textContent = buckets.length;
// Display recent users (max 5)
const recentUsers = users.slice(0, 5);
const tbody = document.getElementById('recent-users');
tbody.innerHTML = '';
if (recentUsers.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="py-8 text-center text-charcoal-300">No users found</td>
</tr>
`;
} else {
recentUsers.forEach(user => {
const row = document.createElement('tr');
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
row.innerHTML = `
<td class="py-3 px-4 font-mono text-sm text-charcoal">${escapeHtml(user.access)}</td>
<td class="py-3 px-4">${formatRole(user.role)}</td>
<td class="py-3 px-4 text-sm text-charcoal">${user.projectid || '-'}</td>
<td class="py-3 px-4 text-sm text-charcoal">${user.userid || '0'}</td>
<td class="py-3 px-4 text-sm text-charcoal">${user.groupid || '0'}</td>
`;
tbody.appendChild(row);
});
}
// Update status
document.getElementById('system-status').innerHTML = `
<span class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></span>
<p class="text-xl font-bold text-green-600">Connected</p>
`;
// An admin session counts through the Admin API; every other session
// here belongs to a standalone-IAM deployment, where the S3 listing is
// the (IAM-policy-gated) source.
const buckets = api.isAdmin() ? await api.listBuckets() : await api.listBucketsS3();
count.textContent = buckets.length;
return true;
} catch (error) {
console.error('Error loading dashboard:', error);
showToast('Error loading dashboard data: ' + error.message, 'error');
document.getElementById('system-status').innerHTML = `
<span class="w-3 h-3 bg-red-500 rounded-full"></span>
<p class="text-xl font-bold text-red-600">Error</p>
`;
console.error('Error loading buckets:', error);
if (error.code === 'AccessDenied') {
count.textContent = '-';
note.textContent = 'You dont have permission to list buckets';
// A denied listing is a permissions answer, not a broken connection.
return true;
}
showToast('Error loading buckets: ' + error.message, 'error');
return false;
}
}
async function loadGatewayUsers() {
let users;
try {
users = await api.listUsers();
} catch (error) {
console.error('Error loading users:', error);
showToast('Error loading users: ' + error.message, 'error');
return false;
}
document.getElementById('user-count').textContent = users.length;
const tbody = document.getElementById('recent-users');
tbody.innerHTML = '';
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="py-8 text-center text-charcoal-300">No users found</td></tr>';
return true;
}
users.slice(0, 5).forEach(user => {
const row = document.createElement('tr');
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
row.innerHTML = `
<td class="py-3 px-4 font-mono text-sm text-charcoal">${escapeHtml(user.access)}</td>
<td class="py-3 px-4">${formatRole(user.role)}</td>
<td class="py-3 px-4 text-sm text-charcoal">${user.projectid || '-'}</td>
<td class="py-3 px-4 text-sm text-charcoal">${user.userid || '0'}</td>
<td class="py-3 px-4 text-sm text-charcoal">${user.groupid || '0'}</td>
`;
tbody.appendChild(row);
});
return true;
}
async function loadIamUsers() {
const count = document.getElementById('iam-user-count');
const note = document.getElementById('iam-user-count-note');
const tbody = document.getElementById('recent-iam-users');
let users;
try {
({ users } = await api.iamListUsers());
} catch (error) {
count.textContent = '-';
note.textContent = iamIsAccessDenied(error)
? 'You don\u2019t have permission to list IAM users'
: iamShortError(error);
iamShowAccessDenied('recent-iam-users', 4, note.textContent);
// A denied listing is a permissions answer, not a broken connection.
return iamIsAccessDenied(error);
}
count.textContent = String(users.length);
tbody.innerHTML = '';
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="py-8 text-center text-charcoal-300">No IAM users yet</td></tr>';
return true;
}
users.slice(0, 5).forEach(user => {
const row = document.createElement('tr');
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
row.innerHTML = `
<td class="py-3 px-4 text-sm font-medium text-charcoal">${escapeHtml(user.UserName || '-')}</td>
<td class="py-3 px-4 text-sm text-charcoal font-mono">${escapeHtml(user.Path || '/')}</td>
<td class="py-3 px-4 text-sm">${iamArnCell(user.Arn || '')}</td>
<td class="py-3 px-4 text-sm text-charcoal">${escapeHtml(iamFormatDate(user.CreateDate))}</td>
`;
tbody.appendChild(row);
});
return true;
}
</script>
</body>
</html>
+77 -25
View File
@@ -50,35 +50,45 @@ under the License.
</a>
</div>
<nav class="flex-1 py-4">
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-admin-only>
Admin
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-management-only>
Management
</div>
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
<span class="font-medium">Dashboard</span>
</a>
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only data-admin-users-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<span class="font-medium">Users</span>
</a>
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
<span class="font-medium">Buckets</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-admin-only></div>
<a href="explorer.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white">
<div class="mx-6 my-2 border-t border-white/10" data-management-only></div>
<a href="explorer.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-s3-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span class="font-medium">Explorer</span>
</a>
<div class="mx-6 my-2 border-t border-white/10"></div>
<div class="mx-6 my-2 border-t border-white/10" data-s3-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-iam-only>
Identity &amp; Access
</div>
<a href="iam.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-iam-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span class="font-medium">IAM</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-iam-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
Resources
</div>
@@ -88,13 +98,13 @@ under the License.
</svg>
<span class="font-medium">Documentation</span>
</a>
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span class="font-medium">Bug Reports</span>
</a>
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
@@ -1140,8 +1150,8 @@ under the License.
let bucketsObjectLockCache = {};
// Auth guard
if (!requireAuth()) {
// Will redirect to login
if (!requireS3()) {
// Will redirect to login, or to the IAM dashboard for an IAM-only session
} else {
// Clear history state on startup (can happen when reloading with popup open)
if (history.state?.modal) history.back();
@@ -1213,6 +1223,25 @@ under the License.
</tr>
`;
// A deep link (URL hash, favorite) names its bucket directly, so jump
// straight to it: policy access to one bucket does not imply
// s3:ListAllMyBuckets.
if (currentBucket) {
showObjectsView();
await checkBucketVersioning();
await loadObjects();
return;
}
// Same for the buckets-view landing: skip the doomed call and go
// straight to the "here's what you can still do" state, rather than
// showing it as a generic failure after a round trip.
if (!api.isAdmin() && !api.canListBuckets()) {
showBucketsView();
showListBucketsDenied();
return;
}
try {
if (api.isAdmin()) {
// Admin: use admin API to get all buckets with owner info
@@ -1224,21 +1253,19 @@ under the License.
// Load versioning status for all buckets
await loadAllBucketsVersioning();
// Load object lock configuration for all buckets
await loadAllBucketsObjectLock();
// If we have a bucket from URL, show it
if (currentBucket) {
showObjectsView();
await checkBucketVersioning();
await loadObjects();
} else {
showBucketsView();
renderBuckets();
}
showBucketsView();
renderBuckets();
} catch (error) {
console.error('Error loading buckets:', error);
if (error.code === 'AccessDenied') {
showBucketsView();
showListBucketsDenied();
return;
}
showToast('Error loading buckets: ' + error.message, 'error');
tbody.innerHTML = `
<tr>
@@ -1254,6 +1281,29 @@ under the License.
}
}
/**
* The account-wide listing is denied, but specific buckets may still be
* reachable by name, so leave the "go to bucket" input and favorites
* usable instead of dead-ending the page.
*/
function showListBucketsDenied() {
const createdHeader = document.getElementById('buckets-created-header');
const createdCol = document.getElementById('buckets-created-col');
if (createdHeader) createdHeader.style.display = 'none';
if (createdCol) createdCol.style.display = 'none';
document.getElementById('buckets-table').innerHTML = `
<tr>
<td colspan="5" class="py-12 text-center">
<svg class="w-16 h-16 text-yellow-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
<p class="text-lg font-medium text-charcoal">You don't have permission to list all buckets</p>
<p class="text-sm text-charcoal-300 mt-1">If you know the name of a bucket you have access to, open it above.</p>
</td>
</tr>
`;
}
async function loadAllBucketsVersioning() {
const promises = bucketsList.map(async (bucket) => {
const name = bucket.name || bucket.Name;
@@ -1445,12 +1495,14 @@ under the License.
document.getElementById('search-container').classList.remove('hidden');
}
function goToBuckets() {
async function goToBuckets() {
currentBucket = null;
currentPrefix = '';
showBucketsView();
renderBuckets();
updateUrl();
// Re-fetch rather than render a stale/empty cache: a deep link can land
// on a bucket without ever populating bucketsList, and loadBuckets()
// also re-derives whether the account-wide listing is allowed.
await loadBuckets();
}
async function selectBucketChecked(name) {
+667
View File
@@ -0,0 +1,667 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VersityGW Admin - OIDC Providers</title>
<script src="assets/js/crypto-js.min.js"></script>
<script src="assets/js/tailwind.js"></script>
<script src="assets/css/tailwind-config.js"></script>
<link rel="stylesheet" href="assets/css/fonts.css">
<link rel="stylesheet" href="assets/css/theme.css">
<link rel="icon" type="image/png" href="assets/images/favicon.png">
</head>
<body class="min-h-screen bg-surface">
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<script src="js/iam-ui.js"></script>
<div class="relative flex h-screen overflow-hidden">
<input id="sidebar-toggle" type="checkbox" class="peer hidden"/>
<label for="sidebar-toggle" aria-label="Toggle navigation" class="
sm:hidden rotate-180 peer-checked:rotate-0 absolute z-20 top-[14px] left-6
flex justify-center items-center p-2 rounded-lg transition-all
text-charcoal-300 hover:text-charcoal hover:bg-gray-100
peer-checked:text-white/70 peer-checked:hover:text-white peer-checked:hover:bg-white/10
">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="-0.5 0 25 25">
<path stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="M7.6728 22L16.1434 13.0294C16.4081 12.75 16.4081 12.3088 16.1434 12.0147L7.65808 3" />
</svg>
</label>
<!-- Sidebar -->
<aside class="absolute z-10 sm:static -translate-x-60 peer-checked:translate-x-0 sm:!translate-x-0 w-60 h-screen bg-charcoal flex flex-col overflow-auto transition-all">
<div class="ml-12 sm:ml-0 h-16 flex-shrink-0 flex items-center px-6 border-b border-white/10">
<a href="https://www.versity.com" target="_blank" rel="noopener noreferrer">
<img src="assets/images/Versity-logo-white-horizontal.png" alt="Versity" class="h-10 hover:opacity-80 transition-opacity">
</a>
</div>
<nav class="flex-1 py-4">
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-management-only>
Management
</div>
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
<span class="font-medium">Dashboard</span>
</a>
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only data-admin-users-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<span class="font-medium">Users</span>
</a>
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
<span class="font-medium">Buckets</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-management-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-s3-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span class="font-medium">Explorer</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-s3-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-iam-only>
Identity &amp; Access
</div>
<a href="iam.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-iam-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span class="font-medium">IAM</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-iam-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
Resources
</div>
<a href="https://github.com/versity/versitygw/wiki" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<span class="font-medium">Documentation</span>
</a>
<a href="https://github.com/versity/versitygw" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
<span class="font-medium">GitHub</span>
</a>
</nav>
<div class="p-4 border-t border-white/10">
<div id="user-info" class="flex items-center gap-3 mb-3"></div>
<button onclick="api.logout(); window.location.href='index.html';" class="w-full flex items-center gap-2 px-3 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors text-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
Sign Out
</button>
</div>
</aside>
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-hidden">
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0">
<h1 class="ml-12 sm:ml-0 text-xl font-semibold text-charcoal">VersityGW OIDC Providers</h1>
<button onclick="loadProviders()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</header>
<main class="flex-1 overflow-auto p-6">
<div class="max-w-7xl mx-auto">
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold text-charcoal">OIDC Providers</h1>
<p class="text-charcoal-300 mt-1">Manage OpenID Connect identity providers</p>
</div>
<button onclick="openCreateProviderModal()" class="flex items-center gap-2 bg-primary hover:bg-primary-600 text-white font-medium py-2.5 px-4 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
Create Provider
</button>
</div>
<!-- Search -->
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 mb-6">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-charcoal-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" id="search-input" placeholder="Search providers..." oninput="filterProviders()" class="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg text-charcoal placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
</div>
</div>
<!-- Providers Table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Provider</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">ARN</th>
<th class="text-right py-4 px-6 text-sm font-semibold text-charcoal">Actions</th>
</tr>
</thead>
<tbody id="providers-table-body"></tbody>
</table>
</div>
<div class="border-t border-gray-100 px-6 py-3">
<p class="text-xs text-charcoal-300">This list returns provider ARNs only and is not paginated. URL, client IDs and thumbprints load when you open a provider.</p>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Create Provider Modal -->
<div id="create-provider-modal" class="modal hidden fixed inset-0 z-50">
<div class="modal-backdrop absolute inset-0" onclick="closeModal('create-provider-modal')"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg relative max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
<h2 class="text-xl font-semibold text-charcoal">Create OIDC Provider</h2>
<button onclick="closeModal('create-provider-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="flex-1 overflow-auto">
<form id="create-provider-form" class="p-6 space-y-5">
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Provider URL <span class="text-red-500">*</span></label>
<input type="text" id="create-provider-url" maxlength="255" placeholder="https://token.example.com" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:font-sans placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<p class="mt-2 text-xs text-charcoal-300">Must start with https://. No port, user info, query string or fragment. The URL cannot be changed after creation.</p>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-charcoal">Audience(s) / Client ID(s)</label>
<button type="button" onclick="iamAddTextRow('create-provider-clients', '', 'sts.versity.local', 255)" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Add Client ID</button>
</div>
<div id="create-provider-clients" class="space-y-2"></div>
<p class="mt-2 text-xs text-charcoal-300">Up to 100 entries, each 255 characters or fewer. Client IDs are added and removed one at a time after creation.</p>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-charcoal">Thumbprints</label>
<button type="button" onclick="iamAddTextRow('create-provider-thumbprints', '', '40-character SHA-1 thumbprint', 40)" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Add Thumbprint</button>
</div>
<div id="create-provider-thumbprints" class="space-y-2"></div>
<p class="mt-2 text-xs text-charcoal-300">Leave blank and the server fetches the thumbprint over a live TLS connection to the URL. If the operator started the service with --disable-oidc-thumbprint-autofetch, a blank list will error. Up to 5 entries, each exactly 40 characters.</p>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-charcoal">Tags</label>
<button type="button" onclick="iamAddTagRow('create-provider-tags')" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Add Tag</button>
</div>
<div id="create-provider-tags" class="space-y-2"></div>
<p class="mt-2 text-xs text-charcoal-300">Tags are set at creation only.</p>
</div>
</form>
</div>
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100 flex-shrink-0">
<button onclick="closeModal('create-provider-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button id="create-provider-btn" onclick="submitCreateProvider()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">Create Provider</button>
</div>
</div>
</div>
</div>
<!-- Manage Provider Modal -->
<div id="manage-provider-modal" class="modal hidden fixed inset-0 z-50">
<div class="modal-backdrop absolute inset-0" onclick="closeModal('manage-provider-modal')"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-3xl relative max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
<div>
<h2 id="manage-provider-title" class="text-xl font-semibold text-charcoal">Provider</h2>
<p class="text-sm text-charcoal-300 mt-1">URL and tags are fixed at creation. Client IDs change one at a time; thumbprints are replaced as a whole list.</p>
</div>
<button onclick="closeModal('manage-provider-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="flex-1 overflow-auto">
<div class="p-6 space-y-6">
<div class="bg-surface border border-gray-100 rounded-lg p-4">
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div class="sm:col-span-2">
<dt class="text-charcoal-300">ARN</dt>
<dd id="provider-detail-arn" class="mt-1">-</dd>
</div>
<div>
<dt class="text-charcoal-300">URL</dt>
<dd id="provider-detail-url" class="mt-1 font-mono text-xs text-charcoal">-</dd>
</div>
<div>
<dt class="text-charcoal-300">Created</dt>
<dd id="provider-detail-created" class="mt-1 text-charcoal">-</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-charcoal-300">Tags</dt>
<dd id="provider-detail-tags" class="mt-1 flex flex-wrap gap-2">-</dd>
</div>
</dl>
</div>
<!-- Client IDs -->
<div>
<h3 class="text-sm font-semibold text-charcoal mb-1">Audiences / Client IDs</h3>
<p class="text-xs text-charcoal-300 mb-3">Each addition and removal is a separate call. Up to 100 entries.</p>
<div id="provider-clients" class="flex flex-wrap gap-2 mb-3"></div>
<div class="flex gap-2">
<input type="text" id="add-client-id" maxlength="255" placeholder="Add a client ID" class="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono text-charcoal placeholder:font-sans placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<button id="add-client-btn" onclick="addClientId()" class="px-4 py-2 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors text-sm">Add</button>
</div>
</div>
<!-- Thumbprints -->
<div>
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-semibold text-charcoal">Thumbprints</h3>
<p class="text-xs text-charcoal-300 mt-1">Saving replaces all thumbprints for this provider.</p>
</div>
<button onclick="openThumbprintsModal()" class="px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 font-medium rounded-lg transition-colors">Replace Thumbprints</button>
</div>
<div id="provider-thumbprints" class="space-y-2"></div>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100 flex-shrink-0">
<button onclick="closeModal('manage-provider-modal')" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">Done</button>
</div>
</div>
</div>
</div>
<!-- Replace Thumbprints Modal -->
<div id="thumbprints-modal" class="modal hidden fixed inset-0 z-50">
<div class="modal-backdrop absolute inset-0" onclick="closeModal('thumbprints-modal')"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg relative">
<div class="flex items-center justify-between p-6 border-b border-gray-100">
<h2 class="text-xl font-semibold text-charcoal">Replace Thumbprints</h2>
<button onclick="closeModal('thumbprints-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-6 space-y-4">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p class="text-sm text-yellow-800">Saving will replace all thumbprints for this provider. Entries not listed here are removed.</p>
</div>
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-charcoal">Thumbprints</label>
<button type="button" onclick="iamAddTextRow('edit-thumbprints', '', '40-character SHA-1 thumbprint', 40)" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Add Thumbprint</button>
</div>
<div id="edit-thumbprints" class="space-y-2"></div>
<p class="text-xs text-charcoal-300">At least one entry is required. Up to 5 entries, each exactly 40 characters.</p>
</div>
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
<button onclick="closeModal('thumbprints-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button id="save-thumbprints-btn" onclick="saveThumbprints()" class="px-4 py-2.5 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">Replace Thumbprints</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-provider-modal" class="modal hidden fixed inset-0 z-50">
<div class="modal-backdrop absolute inset-0" onclick="closeModal('delete-provider-modal')"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
<div class="p-6">
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-charcoal text-center mb-2">Delete OIDC Provider</h3>
<p class="text-charcoal-300 text-center mb-6">
Are you sure you want to delete <span id="delete-provider-name" class="font-mono text-charcoal"></span>? Roles trusting this provider will stop accepting its tokens. This action cannot be undone.
</p>
<div class="flex items-center justify-center gap-3">
<button onclick="closeModal('delete-provider-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button id="confirm-delete-provider-btn" onclick="confirmDeleteProvider()" class="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">Delete Provider</button>
</div>
</div>
</div>
</div>
</div>
<script>
let allProviders = []; // ARNs
let currentProvider = null; // { arn, url, clientIDList, thumbprintList, createDate, tags }
let providerToDelete = null;
if (!requireIAM()) {
// Redirected
} else {
initSidebarWithRole();
updateUserInfo();
loadProviders();
}
// ============================================
// List
// ============================================
/**
* The provider host is encoded in the ARN, so each row can be named
* without a GetOpenIDConnectProvider call of its own.
*/
function providerNameFromArn(arn) {
const index = arn.indexOf('oidc-provider/');
return index === -1 ? arn : arn.slice(index + 'oidc-provider/'.length);
}
async function loadProviders() {
showTableLoading('providers-table-body', 3);
try {
allProviders = await api.iamListOIDCProviders();
filterProviders();
} catch (error) {
console.error('Error loading OIDC providers:', error);
if (iamIsAccessDenied(error)) {
iamShowAccessDenied('providers-table-body', 3, 'You don\u2019t have permission to list identity providers');
} else {
showToast(iamErrorText(error, 'loading identity providers'), 'error');
showEmptyState('providers-table-body', 3, 'Error loading identity providers');
}
}
}
function filterProviders() {
const term = document.getElementById('search-input').value.toLowerCase();
const filtered = term ? allProviders.filter(arn => arn.toLowerCase().includes(term)) : allProviders;
renderProviders(filtered);
}
function renderProviders(providers) {
const tbody = document.getElementById('providers-table-body');
tbody.innerHTML = '';
if (providers.length === 0) {
showEmptyState('providers-table-body', 3, 'No identity providers found');
return;
}
providers.forEach(arn => {
const row = document.createElement('tr');
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
row.innerHTML = `
<td class="py-4 px-6"><span class="font-mono text-sm text-charcoal">${escapeHtml(providerNameFromArn(arn))}</span></td>
<td class="py-4 px-6">${iamArnCell(arn)}</td>
<td class="py-4 px-6 text-right">
<div class="flex items-center justify-end gap-2">
<button onclick="openManageProviderModal('${escapeHtml(arn)}')" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal font-medium rounded-lg transition-colors">Manage</button>
<button onclick="openDeleteProviderModal('${escapeHtml(arn)}')" class="p-2 text-charcoal-300 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Delete">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>`;
tbody.appendChild(row);
});
}
// ============================================
// Create
// ============================================
function openCreateProviderModal() {
document.getElementById('create-provider-url').value = '';
document.getElementById('create-provider-clients').innerHTML = '';
document.getElementById('create-provider-thumbprints').innerHTML = '';
document.getElementById('create-provider-tags').innerHTML = '';
iamAddTextRow('create-provider-clients', '', 'sts.versity.local', 255);
openModal('create-provider-modal');
}
function validateProviderUrl(url) {
if (!url) return 'Provider URL is required.';
if (!url.startsWith('https://')) return 'Provider URL must start with https://.';
if (url.length > IAM_LIMITS.oidcUrlChars) return `Provider URL must be ${IAM_LIMITS.oidcUrlChars} characters or fewer.`;
let parsed;
try {
parsed = new URL(url);
} catch (e) {
return 'Provider URL is not a valid URL.';
}
if (parsed.port) return 'Provider URL must not include a port.';
if (parsed.username || parsed.password) return 'Provider URL must not include user info.';
if (parsed.search) return 'Provider URL must not include a query string.';
if (parsed.hash) return 'Provider URL must not include a fragment.';
return null;
}
function validateThumbprints(list, requireOne) {
if (list.length === 0) return requireOne ? 'At least one thumbprint is required.' : null;
if (list.length > IAM_LIMITS.oidcThumbprints) return `Up to ${IAM_LIMITS.oidcThumbprints} thumbprints are allowed.`;
const bad = list.find(t => t.length !== IAM_LIMITS.oidcThumbprintChars);
if (bad) return `Thumbprints must be exactly ${IAM_LIMITS.oidcThumbprintChars} characters. "${bad}" is ${bad.length}.`;
return null;
}
async function submitCreateProvider() {
const url = document.getElementById('create-provider-url').value.trim();
const clients = iamCollectTextRows('create-provider-clients');
const thumbprints = iamCollectTextRows('create-provider-thumbprints');
const tags = iamCollectTags('create-provider-tags');
const urlError = validateProviderUrl(url);
if (urlError) { showToast(urlError, 'error'); return; }
if (clients.length > IAM_LIMITS.oidcClientIds) {
showToast(`Up to ${IAM_LIMITS.oidcClientIds} client IDs are allowed.`, 'error');
return;
}
const thumbprintError = validateThumbprints(thumbprints, false);
if (thumbprintError) { showToast(thumbprintError, 'error'); return; }
const btn = document.getElementById('create-provider-btn');
setLoading(btn, true);
try {
await api.iamCreateOIDCProvider(url, clients, thumbprints, tags);
showToast('Identity provider created successfully', 'success');
closeModal('create-provider-modal');
loadProviders();
} catch (error) {
console.error('Error creating identity provider:', error);
showToast(iamErrorText(error, 'creating identity provider'), 'error');
} finally {
setLoading(btn, false);
}
}
// ============================================
// Manage
// ============================================
async function openManageProviderModal(arn) {
currentProvider = { arn };
document.getElementById('manage-provider-title').textContent = providerNameFromArn(arn);
document.getElementById('provider-detail-arn').innerHTML = iamArnCell(arn);
document.getElementById('provider-detail-url').textContent = 'Loading...';
document.getElementById('provider-detail-created').textContent = '-';
document.getElementById('provider-detail-tags').innerHTML = '<span class="text-charcoal-300">-</span>';
document.getElementById('provider-clients').innerHTML = '';
document.getElementById('provider-thumbprints').innerHTML = '';
openModal('manage-provider-modal');
await loadProviderDetail();
}
async function loadProviderDetail() {
try {
const detail = await api.iamGetOIDCProvider(currentProvider.arn);
currentProvider = Object.assign({ arn: currentProvider.arn }, detail);
document.getElementById('provider-detail-url').textContent = detail.url || '-';
document.getElementById('provider-detail-created').textContent = iamFormatDate(detail.createDate);
const tagsEl = document.getElementById('provider-detail-tags');
tagsEl.innerHTML = detail.tags.length === 0
? '<span class="text-charcoal-300">-</span>'
: detail.tags.map(tag => `<span class="px-2 py-0.5 bg-gray-100 text-charcoal text-xs font-mono rounded">${escapeHtml(tag.Key)}=${escapeHtml(tag.Value || '')}</span>`).join('');
renderClientIds(detail.clientIDList);
renderThumbprints(detail.thumbprintList);
} catch (error) {
console.error('Error loading provider:', error);
document.getElementById('provider-detail-url').textContent = iamIsAccessDenied(error)
? 'No permission to read this provider'
: 'Unavailable';
if (!iamIsAccessDenied(error)) showToast(iamErrorText(error, 'loading provider'), 'error');
}
}
function renderClientIds(clients) {
const el = document.getElementById('provider-clients');
if (!clients || clients.length === 0) {
el.innerHTML = '<span class="text-sm text-charcoal-300">No client IDs</span>';
return;
}
el.innerHTML = clients.map(id => `
<span class="inline-flex items-center gap-2 pl-3 pr-2 py-1.5 bg-gray-100 rounded-lg">
<span class="font-mono text-xs text-charcoal">${escapeHtml(id)}</span>
<button onclick="removeClientId('${escapeHtml(id)}')" class="p-0.5 text-charcoal-300 hover:text-red-600 rounded transition-colors" title="Remove">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</span>`).join('');
}
function renderThumbprints(thumbprints) {
const el = document.getElementById('provider-thumbprints');
if (!thumbprints || thumbprints.length === 0) {
el.innerHTML = '<span class="text-sm text-charcoal-300">No thumbprints</span>';
return;
}
el.innerHTML = thumbprints.map(tp => `
<div class="flex items-center gap-2 border border-gray-100 rounded-lg px-3 py-2">
<span class="font-mono text-xs text-charcoal break-all">${escapeHtml(tp)}</span>
<button onclick="iamCopy('${escapeHtml(tp)}', 'Thumbprint')" class="ml-auto p-1 text-charcoal-300 hover:text-accent hover:bg-accent-50 rounded transition-colors flex-shrink-0" title="Copy">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>`).join('');
}
async function addClientId() {
const input = document.getElementById('add-client-id');
const clientId = input.value.trim();
if (!clientId) return;
if (clientId.length > IAM_LIMITS.oidcClientIdChars) {
showToast(`Client IDs must be ${IAM_LIMITS.oidcClientIdChars} characters or fewer.`, 'error');
return;
}
const btn = document.getElementById('add-client-btn');
setLoading(btn, true);
try {
await api.iamAddClientIDToOIDCProvider(currentProvider.arn, clientId);
showToast('Client ID added successfully', 'success');
input.value = '';
await loadProviderDetail();
} catch (error) {
showToast(iamErrorText(error, 'adding client ID'), 'error');
} finally {
setLoading(btn, false);
}
}
function removeClientId(clientId) {
confirm(`Remove client ID ${clientId} from this provider?`, async () => {
try {
await api.iamRemoveClientIDFromOIDCProvider(currentProvider.arn, clientId);
showToast('Client ID removed successfully', 'success');
await loadProviderDetail();
} catch (error) {
showToast(iamErrorText(error, 'removing client ID'), 'error');
}
});
}
function openThumbprintsModal() {
const container = document.getElementById('edit-thumbprints');
container.innerHTML = '';
const current = (currentProvider && currentProvider.thumbprintList) || [];
if (current.length === 0) {
iamAddTextRow('edit-thumbprints', '', '40-character SHA-1 thumbprint', 40);
} else {
current.forEach(tp => iamAddTextRow('edit-thumbprints', tp, '40-character SHA-1 thumbprint', 40));
}
openModal('thumbprints-modal');
}
async function saveThumbprints() {
const thumbprints = iamCollectTextRows('edit-thumbprints');
const error = validateThumbprints(thumbprints, true);
if (error) { showToast(error, 'error'); return; }
const btn = document.getElementById('save-thumbprints-btn');
setLoading(btn, true);
try {
await api.iamUpdateOIDCProviderThumbprint(currentProvider.arn, thumbprints);
showToast('Thumbprints replaced successfully', 'success');
closeModal('thumbprints-modal');
await loadProviderDetail();
} catch (err) {
showToast(iamErrorText(err, 'replacing thumbprints'), 'error');
} finally {
setLoading(btn, false);
}
}
// ============================================
// Delete
// ============================================
function openDeleteProviderModal(arn) {
providerToDelete = arn;
document.getElementById('delete-provider-name').textContent = providerNameFromArn(arn);
openModal('delete-provider-modal');
}
async function confirmDeleteProvider() {
if (!providerToDelete) return;
const btn = document.getElementById('confirm-delete-provider-btn');
setLoading(btn, true);
try {
await api.iamDeleteOIDCProvider(providerToDelete);
showToast('Identity provider deleted successfully', 'success');
closeModal('delete-provider-modal');
providerToDelete = null;
loadProviders();
} catch (error) {
console.error('Error deleting identity provider:', error);
showToast(iamErrorText(error, 'deleting identity provider'), 'error');
} finally {
setLoading(btn, false);
}
}
</script>
</body>
</html>
+775
View File
@@ -0,0 +1,775 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VersityGW Admin - IAM Roles</title>
<script src="assets/js/crypto-js.min.js"></script>
<script src="assets/js/tailwind.js"></script>
<script src="assets/css/tailwind-config.js"></script>
<link rel="stylesheet" href="assets/css/fonts.css">
<link rel="stylesheet" href="assets/css/theme.css">
<link rel="icon" type="image/png" href="assets/images/favicon.png">
</head>
<body class="min-h-screen bg-surface">
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<script src="js/iam-ui.js"></script>
<div class="relative flex h-screen overflow-hidden">
<input id="sidebar-toggle" type="checkbox" class="peer hidden"/>
<label for="sidebar-toggle" aria-label="Toggle navigation" class="
sm:hidden rotate-180 peer-checked:rotate-0 absolute z-20 top-[14px] left-6
flex justify-center items-center p-2 rounded-lg transition-all
text-charcoal-300 hover:text-charcoal hover:bg-gray-100
peer-checked:text-white/70 peer-checked:hover:text-white peer-checked:hover:bg-white/10
">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="-0.5 0 25 25">
<path stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="M7.6728 22L16.1434 13.0294C16.4081 12.75 16.4081 12.3088 16.1434 12.0147L7.65808 3" />
</svg>
</label>
<!-- Sidebar -->
<aside class="absolute z-10 sm:static -translate-x-60 peer-checked:translate-x-0 sm:!translate-x-0 w-60 h-screen bg-charcoal flex flex-col overflow-auto transition-all">
<div class="ml-12 sm:ml-0 h-16 flex-shrink-0 flex items-center px-6 border-b border-white/10">
<a href="https://www.versity.com" target="_blank" rel="noopener noreferrer">
<img src="assets/images/Versity-logo-white-horizontal.png" alt="Versity" class="h-10 hover:opacity-80 transition-opacity">
</a>
</div>
<nav class="flex-1 py-4">
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-management-only>
Management
</div>
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
<span class="font-medium">Dashboard</span>
</a>
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only data-admin-users-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<span class="font-medium">Users</span>
</a>
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
<span class="font-medium">Buckets</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-management-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-s3-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span class="font-medium">Explorer</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-s3-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-iam-only>
Identity &amp; Access
</div>
<a href="iam.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-iam-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span class="font-medium">IAM</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-iam-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
Resources
</div>
<a href="https://github.com/versity/versitygw/wiki" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<span class="font-medium">Documentation</span>
</a>
<a href="https://github.com/versity/versitygw" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
<span class="font-medium">GitHub</span>
</a>
</nav>
<div class="p-4 border-t border-white/10">
<div id="user-info" class="flex items-center gap-3 mb-3"></div>
<button onclick="api.logout(); window.location.href='index.html';" class="w-full flex items-center gap-2 px-3 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors text-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
Sign Out
</button>
</div>
</aside>
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-hidden">
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0">
<h1 class="ml-12 sm:ml-0 text-xl font-semibold text-charcoal">VersityGW IAM Roles</h1>
<button onclick="loadRoles()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</header>
<main class="flex-1 overflow-auto p-6">
<div class="max-w-7xl mx-auto">
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold text-charcoal">Roles</h1>
<p class="text-charcoal-300 mt-1">Manage IAM roles, trust policies, and inline policies</p>
</div>
<button onclick="openCreateRoleModal()" class="flex items-center gap-2 bg-primary hover:bg-primary-600 text-white font-medium py-2.5 px-4 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
Create Role
</button>
</div>
<!-- Filters & Search -->
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 mb-6">
<div class="flex flex-wrap items-center gap-4">
<div class="relative flex-1 min-w-64">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-charcoal-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" id="search-input" placeholder="Search by role name..." oninput="filterRoles()" class="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg text-charcoal placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
</div>
<div class="relative min-w-56">
<input type="text" id="path-prefix-input" placeholder="Filter by path prefix..." class="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:font-sans placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
</div>
<button onclick="loadRoles()" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Apply</button>
</div>
</div>
<!-- Roles Table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Path</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Role Name</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">ARN</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Created</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Max Session</th>
<th class="text-right py-4 px-6 text-sm font-semibold text-charcoal">Actions</th>
</tr>
</thead>
<tbody id="roles-table-body"></tbody>
</table>
</div>
<div id="load-more-row" class="hidden border-t border-gray-100 p-4 text-center">
<button id="load-more-btn" onclick="loadMoreRoles()" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Load More</button>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Create Role Modal (step 1 of 2) -->
<div id="create-role-modal" class="modal hidden fixed inset-0 z-50">
<div class="modal-backdrop absolute inset-0" onclick="closeModal('create-role-modal')"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg relative max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
<div>
<h2 class="text-xl font-semibold text-charcoal">Create Role</h2>
<p class="text-sm text-charcoal-300 mt-1">Step 1 of 2 &mdash; Role details</p>
</div>
<button onclick="closeModal('create-role-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="flex-1 overflow-auto">
<form id="create-role-form" class="p-6 space-y-5">
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Role Name <span class="text-red-500">*</span></label>
<input type="text" id="create-role-name" maxlength="64" placeholder="e.g., archive-reader" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:font-sans placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<p class="mt-2 text-xs text-charcoal-300">Up to 64 characters. Letters, numbers and + = , . @ _ - only. The name cannot be changed after creation.</p>
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Description</label>
<textarea id="create-role-description" rows="2" maxlength="1000" placeholder="What this role is for" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal text-sm placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all resize-none"></textarea>
<p class="mt-2 text-xs text-charcoal-300">Up to 1000 characters. Not editable after creation.</p>
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Max Session Duration</label>
<div class="flex items-center gap-4">
<input type="range" id="create-role-duration-range" min="3600" max="43200" step="900" value="3600" oninput="syncDuration('range')" class="flex-1 accent-accent">
<div class="w-32">
<input type="number" id="create-role-duration" min="3600" max="43200" step="1" value="3600" oninput="syncDuration('number')" class="w-full px-3 py-2 border-2 border-gray-200 rounded-lg text-charcoal text-sm focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
</div>
</div>
<p id="duration-note" class="mt-2 text-xs text-charcoal-300">3600 seconds (1h 0m). Valid range 3600&ndash;43200 seconds. Not editable after creation.</p>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-charcoal">Tags</label>
<button type="button" onclick="iamAddTagRow('create-role-tags')" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Add Tag</button>
</div>
<div id="create-role-tags" class="space-y-2"></div>
<p class="mt-2 text-xs text-charcoal-300">Tags are set at creation only.</p>
</div>
<details class="group">
<summary class="flex items-center gap-2 cursor-pointer text-sm font-medium text-charcoal-400 hover:text-charcoal transition-colors list-none">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
Advanced Options
</summary>
<div class="mt-4 space-y-4 pl-6">
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Path</label>
<input type="text" id="create-role-path" maxlength="512" placeholder="/" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:font-sans placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<p class="mt-2 text-xs text-charcoal-300">Defaults to /. Must start and end with /.</p>
</div>
</div>
</details>
</form>
</div>
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100 flex-shrink-0">
<button onclick="closeModal('create-role-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button onclick="goToTrustPolicyStep()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">Next: Trust Policy</button>
</div>
</div>
</div>
</div>
<!-- Manage Role Modal -->
<div id="manage-role-modal" class="modal hidden fixed inset-0 z-50">
<div class="modal-backdrop absolute inset-0" onclick="closeModal('manage-role-modal')"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl relative max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
<div>
<h2 id="manage-role-title" class="text-xl font-semibold text-charcoal">Role</h2>
<p class="text-sm text-charcoal-300 mt-1">Only the trust policy and inline policies can be changed after creation</p>
</div>
<button onclick="closeModal('manage-role-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="flex-1 overflow-auto">
<div class="p-6 space-y-6">
<!-- Read-only details -->
<div class="bg-surface border border-gray-100 rounded-lg p-4">
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-charcoal-300">ARN</dt>
<dd id="role-detail-arn" class="mt-1">-</dd>
</div>
<div>
<dt class="text-charcoal-300">Role ID</dt>
<dd id="role-detail-id" class="mt-1 font-mono text-xs text-charcoal">-</dd>
</div>
<div>
<dt class="text-charcoal-300" title="Set at creation, not editable">Path</dt>
<dd id="role-detail-path" class="mt-1 font-mono text-xs text-charcoal">-</dd>
</div>
<div>
<dt class="text-charcoal-300">Created</dt>
<dd id="role-detail-created" class="mt-1 text-charcoal">-</dd>
</div>
<div>
<dt class="text-charcoal-300" title="Set at creation, not editable">Max Session Duration</dt>
<dd id="role-detail-duration" class="mt-1 text-charcoal">-</dd>
</div>
<div>
<dt class="text-charcoal-300" title="Set at creation, not editable">Description</dt>
<dd id="role-detail-description" class="mt-1 text-charcoal">-</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-charcoal-300">Tags</dt>
<dd id="role-detail-tags" class="mt-1 flex flex-wrap gap-2">-</dd>
</div>
</dl>
</div>
<!-- Trust policy -->
<div>
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-semibold text-charcoal">Trust Policy</h3>
<p class="text-xs text-charcoal-300 mt-1">Who may assume this role. Up to 2048 bytes.</p>
</div>
<button onclick="openTrustPolicyEditor()" class="px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 font-medium rounded-lg transition-colors">Edit Trust Policy</button>
</div>
<pre id="role-trust-preview" class="border border-gray-100 rounded-lg bg-gray-50 p-4 text-xs font-mono text-charcoal overflow-auto max-h-48">-</pre>
</div>
<!-- Inline policies -->
<div>
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-semibold text-charcoal">Inline Policies</h3>
<p id="role-policy-quota-note" class="text-xs text-charcoal-300 mt-1">0 / 10240 bytes used</p>
</div>
<button onclick="openRolePolicyEditor()" class="px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 font-medium rounded-lg transition-colors">Add Policy</button>
</div>
<div class="border border-gray-100 rounded-lg overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="text-left py-3 px-4 text-xs font-semibold text-charcoal">Policy Name</th>
<th class="text-left py-3 px-4 text-xs font-semibold text-charcoal">Size</th>
<th class="text-right py-3 px-4 text-xs font-semibold text-charcoal">Actions</th>
</tr>
</thead>
<tbody id="role-policies-table-body"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100 flex-shrink-0">
<button onclick="closeModal('manage-role-modal')" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">Done</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-role-modal" class="modal hidden fixed inset-0 z-50">
<div class="modal-backdrop absolute inset-0" onclick="closeModal('delete-role-modal')"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
<div class="p-6">
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-charcoal text-center mb-2">Delete Role</h3>
<p class="text-charcoal-300 text-center mb-4">
Are you sure you want to delete <span id="delete-role-name" class="font-mono text-charcoal"></span>? This action cannot be undone.
</p>
<p class="text-charcoal-300 text-center text-sm mb-6">Inline policies must be removed first. There is no cascade delete.</p>
<div class="flex items-center justify-center gap-3">
<button onclick="closeModal('delete-role-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button id="confirm-delete-role-btn" onclick="confirmDeleteRole()" class="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">Delete Role</button>
</div>
</div>
</div>
</div>
</div>
<script>
let allRoles = [];
let nextMarker = null;
let currentRole = null;
let rolePolicySizes = {};
let roleToDelete = null;
let pendingRoleDetails = null; // step 1 of the create wizard
if (!requireIAM()) {
// Redirected
} else {
initSidebarWithRole();
updateUserInfo();
loadRoles();
}
// ============================================
// List
// ============================================
async function loadRoles() {
showTableLoading('roles-table-body', 6);
allRoles = [];
nextMarker = null;
await fetchRolePage();
}
async function loadMoreRoles() {
const btn = document.getElementById('load-more-btn');
setLoading(btn, true);
try {
await fetchRolePage();
} finally {
setLoading(btn, false);
}
}
async function fetchRolePage() {
const pathPrefix = document.getElementById('path-prefix-input').value.trim();
try {
const result = await api.iamListRoles({
pathPrefix: pathPrefix || undefined,
marker: nextMarker || undefined,
maxItems: IAM_LIMITS.listPageSize
});
allRoles = allRoles.concat(result.roles);
nextMarker = result.isTruncated ? result.marker : null;
document.getElementById('load-more-row').classList.toggle('hidden', !nextMarker);
filterRoles();
} catch (error) {
console.error('Error loading roles:', error);
document.getElementById('load-more-row').classList.add('hidden');
if (iamIsAccessDenied(error)) {
iamShowAccessDenied('roles-table-body', 6, 'You don\u2019t have permission to list roles');
} else {
showToast(iamErrorText(error, 'loading roles'), 'error');
showEmptyState('roles-table-body', 6, 'Error loading roles');
}
}
}
function filterRoles() {
const term = document.getElementById('search-input').value.toLowerCase();
const filtered = term
? allRoles.filter(r => (r.RoleName || '').toLowerCase().includes(term))
: allRoles;
renderRoles(filtered);
}
function formatDuration(seconds) {
const value = parseInt(seconds, 10) || IAM_LIMITS.minSessionDuration;
const hours = Math.floor(value / 3600);
const minutes = Math.round((value % 3600) / 60);
return `${hours}h ${minutes}m`;
}
function renderRoles(roles) {
const tbody = document.getElementById('roles-table-body');
tbody.innerHTML = '';
if (roles.length === 0) {
showEmptyState('roles-table-body', 6, 'No roles found');
return;
}
roles.forEach(role => {
const name = role.RoleName || '';
const row = document.createElement('tr');
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
row.innerHTML = `
<td class="py-4 px-6"><span class="font-mono text-sm text-charcoal">${escapeHtml(role.Path || '/')}</span></td>
<td class="py-4 px-6"><span class="font-mono text-sm text-charcoal">${escapeHtml(name)}</span></td>
<td class="py-4 px-6">${iamArnCell(role.Arn)}</td>
<td class="py-4 px-6 text-sm text-charcoal">${escapeHtml(iamFormatDate(role.CreateDate))}</td>
<td class="py-4 px-6 text-sm text-charcoal">${escapeHtml(formatDuration(role.MaxSessionDuration))}</td>
<td class="py-4 px-6 text-right">
<div class="flex items-center justify-end gap-2">
<button onclick="openManageRoleModal('${escapeHtml(name)}')" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal font-medium rounded-lg transition-colors">Manage</button>
<button onclick="openDeleteRoleModal('${escapeHtml(name)}')" class="p-2 text-charcoal-300 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Delete">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>`;
tbody.appendChild(row);
});
}
// ============================================
// Create wizard
// ============================================
function openCreateRoleModal() {
document.getElementById('create-role-name').value = '';
document.getElementById('create-role-description').value = '';
document.getElementById('create-role-path').value = '';
document.getElementById('create-role-tags').innerHTML = '';
document.getElementById('create-role-duration').value = IAM_LIMITS.minSessionDuration;
document.getElementById('create-role-duration-range').value = IAM_LIMITS.minSessionDuration;
syncDuration('number');
pendingRoleDetails = null;
openModal('create-role-modal');
}
function syncDuration(source) {
const number = document.getElementById('create-role-duration');
const range = document.getElementById('create-role-duration-range');
let value = parseInt(source === 'range' ? range.value : number.value, 10);
if (isNaN(value)) value = IAM_LIMITS.minSessionDuration;
value = Math.min(IAM_LIMITS.maxSessionDuration, Math.max(IAM_LIMITS.minSessionDuration, value));
if (source === 'range') number.value = value;
else range.value = value;
document.getElementById('duration-note').textContent =
`${value} seconds (${formatDuration(value)}). Valid range 3600\u201343200 seconds. Not editable after creation.`;
}
function goToTrustPolicyStep() {
const roleName = document.getElementById('create-role-name').value.trim();
const description = document.getElementById('create-role-description').value.trim();
const path = document.getElementById('create-role-path').value.trim();
const duration = parseInt(document.getElementById('create-role-duration').value, 10) || IAM_LIMITS.minSessionDuration;
const tags = iamCollectTags('create-role-tags');
const nameError = iamValidateName(roleName, 'Role name');
if (nameError) { showToast(nameError, 'error'); return; }
const pathError = iamValidatePath(path);
if (pathError) { showToast(pathError, 'error'); return; }
if (description.length > IAM_LIMITS.roleDescriptionChars) {
showToast(`Description must be ${IAM_LIMITS.roleDescriptionChars} characters or fewer.`, 'error');
return;
}
pendingRoleDetails = { roleName, description, path, duration, tags };
closeModal('create-role-modal');
iamPolicyEditor.open({
variant: 'trust',
title: 'Create Role',
subtitle: `Step 2 of 2 \u2014 Trust policy for ${roleName}`,
saveLabel: 'Create Role',
document: '',
maxBytes: IAM_LIMITS.trustPolicyBytes,
onSave: async ({ document: doc }) => {
await api.iamCreateRole(pendingRoleDetails.roleName, doc, {
path: pendingRoleDetails.path || undefined,
description: pendingRoleDetails.description || undefined,
maxSessionDuration: pendingRoleDetails.duration,
tags: pendingRoleDetails.tags
});
showToast('Role created successfully', 'success');
pendingRoleDetails = null;
loadRoles();
}
});
}
// ============================================
// Manage
// ============================================
async function openManageRoleModal(roleName) {
currentRole = allRoles.find(r => r.RoleName === roleName) || { RoleName: roleName };
rolePolicySizes = {};
document.getElementById('manage-role-title').textContent = roleName;
renderRoleDetails(currentRole);
openModal('manage-role-modal');
// Refresh from the server so the trust document is current
try {
currentRole = await api.iamGetRole(roleName);
renderRoleDetails(currentRole);
} catch (error) {
if (!iamIsAccessDenied(error)) {
showToast(iamErrorText(error, 'loading role'), 'error');
}
}
loadRolePolicies();
}
function renderRoleDetails(role) {
document.getElementById('role-detail-arn').innerHTML = iamArnCell(role.Arn);
document.getElementById('role-detail-id').textContent = role.RoleId || '-';
document.getElementById('role-detail-path').textContent = role.Path || '/';
document.getElementById('role-detail-created').textContent = iamFormatDate(role.CreateDate);
document.getElementById('role-detail-duration').textContent = role.MaxSessionDuration
? `${role.MaxSessionDuration} seconds (${formatDuration(role.MaxSessionDuration)})`
: '-';
document.getElementById('role-detail-description').textContent = role.Description || '-';
const tagsEl = document.getElementById('role-detail-tags');
const tags = Array.isArray(role.Tags) ? role.Tags : (role.Tags ? [role.Tags] : []);
tagsEl.innerHTML = tags.length === 0
? '<span class="text-charcoal-300">-</span>'
: tags.map(tag => `<span class="px-2 py-0.5 bg-gray-100 text-charcoal text-xs font-mono rounded">${escapeHtml(tag.Key)}=${escapeHtml(tag.Value || '')}</span>`).join('');
const preview = document.getElementById('role-trust-preview');
const trust = role.AssumeRolePolicyDocument || '';
if (!trust) {
preview.textContent = '-';
} else {
try {
preview.textContent = JSON.stringify(JSON.parse(trust), null, 2);
} catch (e) {
preview.textContent = trust;
}
}
}
function openTrustPolicyEditor() {
if (!currentRole) return;
let documentText = currentRole.AssumeRolePolicyDocument || '';
try {
documentText = JSON.stringify(JSON.parse(documentText), null, 2);
} catch (e) {
// Leave as-is
}
iamPolicyEditor.open({
variant: 'trust',
title: 'Trust Policy',
subtitle: `Role ${currentRole.RoleName}`,
document: documentText,
maxBytes: IAM_LIMITS.trustPolicyBytes,
onSave: async ({ document: doc }) => {
await api.iamUpdateAssumeRolePolicy(currentRole.RoleName, doc);
showToast('Trust policy updated successfully', 'success');
currentRole.AssumeRolePolicyDocument = doc;
renderRoleDetails(currentRole);
}
});
}
async function loadRolePolicies() {
const tbody = document.getElementById('role-policies-table-body');
showTableLoading('role-policies-table-body', 3);
rolePolicySizes = {};
try {
const { policyNames } = await api.iamListRolePolicies(currentRole.RoleName);
if (policyNames.length === 0) {
showEmptyState('role-policies-table-body', 3, 'No inline policies');
updateRolePolicyQuotaNote();
return;
}
await Promise.all(policyNames.map(async name => {
try {
const policy = await api.iamGetRolePolicy(currentRole.RoleName, name);
rolePolicySizes[name] = iamByteLength(policy.policyDocument);
} catch (error) {
rolePolicySizes[name] = null;
}
}));
tbody.innerHTML = '';
policyNames.forEach(name => {
const size = rolePolicySizes[name];
const row = document.createElement('tr');
row.className = 'border-b border-gray-50';
row.innerHTML = `
<td class="py-3 px-4"><span class="font-mono text-xs text-charcoal">${escapeHtml(name)}</span></td>
<td class="py-3 px-4 text-xs text-charcoal">${size === null ? '-' : size + ' bytes'}</td>
<td class="py-3 px-4 text-right">
<div class="flex items-center justify-end gap-2">
<button onclick="openRolePolicyEditor('${escapeHtml(name)}')" class="px-2.5 py-1 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Edit</button>
<button onclick="deleteRolePolicy('${escapeHtml(name)}')" class="p-1.5 text-charcoal-300 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Delete">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>`;
tbody.appendChild(row);
});
updateRolePolicyQuotaNote();
} catch (error) {
console.error('Error loading inline policies:', error);
if (iamIsAccessDenied(error)) {
iamShowAccessDenied('role-policies-table-body', 3, 'You don\u2019t have permission to list this role\u2019s inline policies');
} else {
showEmptyState('role-policies-table-body', 3, 'Error loading inline policies: ' + iamShortError(error));
}
}
}
function totalRolePolicyBytes(excludeName) {
return Object.entries(rolePolicySizes).reduce((sum, [name, size]) => {
if (name === excludeName || size === null) return sum;
return sum + size;
}, 0);
}
function updateRolePolicyQuotaNote() {
const used = totalRolePolicyBytes();
const note = document.getElementById('role-policy-quota-note');
note.textContent = `${used} / ${IAM_LIMITS.rolePolicyBytes} bytes used across this role's inline policies`;
note.className = used > IAM_LIMITS.rolePolicyBytes ? 'text-xs text-red-600 font-medium mt-1' : 'text-xs text-charcoal-300 mt-1';
}
async function openRolePolicyEditor(policyName) {
const isNew = !policyName;
let documentText = '';
if (!isNew) {
try {
const policy = await api.iamGetRolePolicy(currentRole.RoleName, policyName);
documentText = policy.policyDocument;
try {
documentText = JSON.stringify(JSON.parse(documentText), null, 2);
} catch (e) {
// Leave the server's text as-is if it is not valid JSON
}
} catch (error) {
showToast(iamErrorText(error, 'loading policy'), 'error');
return;
}
}
iamPolicyEditor.open({
variant: 'identity',
title: isNew ? 'Add Inline Policy' : 'Inline Policy',
subtitle: `Role ${currentRole.RoleName}${isNew ? '' : ' \u2014 ' + policyName}`,
policyName: policyName || '',
nameEditable: isNew,
document: documentText,
quota: { otherBytes: totalRolePolicyBytes(policyName), max: IAM_LIMITS.rolePolicyBytes },
maxBytes: IAM_LIMITS.policyDocumentBytes,
showDelete: !isNew,
onSave: async ({ policyName: name, document: doc }) => {
await api.iamPutRolePolicy(currentRole.RoleName, name, doc);
showToast('Policy saved successfully', 'success');
loadRolePolicies();
},
onDelete: async () => {
await api.iamDeleteRolePolicy(currentRole.RoleName, policyName);
showToast('Policy deleted successfully', 'success');
loadRolePolicies();
}
});
}
function deleteRolePolicy(policyName) {
confirm(`Delete inline policy ${policyName}? This action cannot be undone.`, async () => {
try {
await api.iamDeleteRolePolicy(currentRole.RoleName, policyName);
showToast('Policy deleted successfully', 'success');
loadRolePolicies();
} catch (error) {
showToast(iamErrorText(error, 'deleting policy'), 'error');
}
});
}
// ============================================
// Delete
// ============================================
function openDeleteRoleModal(roleName) {
roleToDelete = roleName;
document.getElementById('delete-role-name').textContent = roleName;
openModal('delete-role-modal');
}
async function confirmDeleteRole() {
if (!roleToDelete) return;
const btn = document.getElementById('confirm-delete-role-btn');
setLoading(btn, true);
try {
await api.iamDeleteRole(roleToDelete);
showToast('Role deleted successfully', 'success');
closeModal('delete-role-modal');
roleToDelete = null;
loadRoles();
} catch (error) {
console.error('Error deleting role:', error);
showToast(iamErrorText(error, 'deleting role'), 'error');
} finally {
setLoading(btn, false);
}
}
</script>
</body>
</html>
+998
View File
@@ -0,0 +1,998 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VersityGW Admin - IAM Users</title>
<script src="assets/js/crypto-js.min.js"></script>
<script src="assets/js/tailwind.js"></script>
<script src="assets/css/tailwind-config.js"></script>
<link rel="stylesheet" href="assets/css/fonts.css">
<link rel="stylesheet" href="assets/css/theme.css">
<link rel="icon" type="image/png" href="assets/images/favicon.png">
</head>
<body class="min-h-screen bg-surface">
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<script src="js/iam-ui.js"></script>
<div class="relative flex h-screen overflow-hidden">
<input id="sidebar-toggle" type="checkbox" class="peer hidden"/>
<label for="sidebar-toggle" aria-label="Toggle navigation" class="
sm:hidden rotate-180 peer-checked:rotate-0 absolute z-20 top-[14px] left-6
flex justify-center items-center p-2 rounded-lg transition-all
text-charcoal-300 hover:text-charcoal hover:bg-gray-100
peer-checked:text-white/70 peer-checked:hover:text-white peer-checked:hover:bg-white/10
">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="-0.5 0 25 25">
<path stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="M7.6728 22L16.1434 13.0294C16.4081 12.75 16.4081 12.3088 16.1434 12.0147L7.65808 3" />
</svg>
</label>
<!-- Sidebar -->
<aside class="absolute z-10 sm:static -translate-x-60 peer-checked:translate-x-0 sm:!translate-x-0 w-60 h-screen bg-charcoal flex flex-col overflow-auto transition-all">
<div class="ml-12 sm:ml-0 h-16 flex-shrink-0 flex items-center px-6 border-b border-white/10">
<a href="https://www.versity.com" target="_blank" rel="noopener noreferrer">
<img src="assets/images/Versity-logo-white-horizontal.png" alt="Versity" class="h-10 hover:opacity-80 transition-opacity">
</a>
</div>
<nav class="flex-1 py-4">
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-management-only>
Management
</div>
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
<span class="font-medium">Dashboard</span>
</a>
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only data-admin-users-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<span class="font-medium">Users</span>
</a>
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
<span class="font-medium">Buckets</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-management-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-s3-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span class="font-medium">Explorer</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-s3-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-iam-only>
Identity &amp; Access
</div>
<a href="iam.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-iam-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span class="font-medium">IAM</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-iam-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
Resources
</div>
<a href="https://github.com/versity/versitygw/wiki" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<span class="font-medium">Documentation</span>
</a>
<a href="https://github.com/versity/versitygw" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
<span class="font-medium">GitHub</span>
</a>
</nav>
<div class="p-4 border-t border-white/10">
<div id="user-info" class="flex items-center gap-3 mb-3"></div>
<button onclick="api.logout(); window.location.href='index.html';" class="w-full flex items-center gap-2 px-3 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors text-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
Sign Out
</button>
</div>
</aside>
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-hidden">
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0">
<h1 class="ml-12 sm:ml-0 text-xl font-semibold text-charcoal">VersityGW IAM Users</h1>
<button onclick="loadUsers()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</header>
<main class="flex-1 overflow-auto p-6">
<div class="max-w-7xl mx-auto">
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold text-charcoal">IAM Users</h1>
<p class="text-charcoal-300 mt-1">Manage IAM users, access keys, and inline policies</p>
</div>
<button onclick="openCreateUserModal()" class="flex items-center gap-2 bg-primary hover:bg-primary-600 text-white font-medium py-2.5 px-4 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
Create IAM User
</button>
</div>
<!-- Filters & Search -->
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 mb-6">
<div class="flex flex-wrap items-center gap-4">
<div class="relative flex-1 min-w-64">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-charcoal-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" id="search-input" placeholder="Search by user name..." oninput="filterUsers()" class="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg text-charcoal placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
</div>
<div class="relative min-w-56">
<input type="text" id="path-prefix-input" placeholder="Filter by path prefix..." class="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:font-sans placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
</div>
<button onclick="loadUsers()" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Apply</button>
</div>
</div>
<!-- Users Table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Path</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">User Name</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">ARN</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Created</th>
<th class="text-right py-4 px-6 text-sm font-semibold text-charcoal">Actions</th>
</tr>
</thead>
<tbody id="users-table-body"></tbody>
</table>
</div>
<div id="load-more-row" class="hidden border-t border-gray-100 p-4 text-center">
<button id="load-more-btn" onclick="loadMoreUsers()" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Load More</button>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Create User Modal -->
<div id="create-user-modal" class="modal hidden fixed inset-0 z-50">
<div class="modal-backdrop absolute inset-0" onclick="closeModal('create-user-modal')"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg relative max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
<h2 class="text-xl font-semibold text-charcoal">Create IAM User</h2>
<button onclick="closeModal('create-user-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="flex-1 overflow-auto">
<form id="create-user-form" class="p-6 space-y-5">
<div>
<label class="block text-sm font-medium text-charcoal mb-2">User Name <span class="text-red-500">*</span></label>
<input type="text" id="create-user-name" maxlength="64" placeholder="e.g., alice" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:font-sans placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<p class="mt-2 text-xs text-charcoal-300">Up to 64 characters. Letters, numbers and + = , . @ _ - only.</p>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-charcoal">Tags</label>
<button type="button" onclick="iamAddTagRow('create-user-tags')" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Add Tag</button>
</div>
<div id="create-user-tags" class="space-y-2"></div>
<p class="mt-2 text-xs text-charcoal-300">Tags are set at creation only. This API has no tag-update action, so they are read-only afterwards.</p>
</div>
<details class="group">
<summary class="flex items-center gap-2 cursor-pointer text-sm font-medium text-charcoal-400 hover:text-charcoal transition-colors list-none">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
Advanced Options
</summary>
<div class="mt-4 space-y-4 pl-6">
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Path</label>
<input type="text" id="create-user-path" maxlength="512" placeholder="/" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:font-sans placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<p class="mt-2 text-xs text-charcoal-300">Defaults to /. Must start and end with /.</p>
</div>
</div>
</details>
</form>
</div>
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100 flex-shrink-0">
<button onclick="closeModal('create-user-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button id="create-user-btn" onclick="submitCreateUser()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">Create User</button>
</div>
</div>
</div>
</div>
<!-- Manage User Modal -->
<div id="manage-user-modal" class="modal hidden fixed inset-0 z-50">
<div class="modal-backdrop absolute inset-0" onclick="closeModal('manage-user-modal')"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl relative max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
<div>
<h2 id="manage-user-title" class="text-xl font-semibold text-charcoal">User</h2>
<p id="manage-user-subtitle" class="text-sm text-charcoal-300 mt-1"></p>
</div>
<button onclick="closeModal('manage-user-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="flex-1 overflow-auto">
<div class="p-6 space-y-6">
<!-- Details -->
<div class="bg-surface border border-gray-100 rounded-lg p-4">
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-charcoal-300">ARN</dt>
<dd id="detail-arn" class="mt-1">-</dd>
</div>
<div>
<dt class="text-charcoal-300">User ID</dt>
<dd id="detail-userid" class="mt-1 font-mono text-xs text-charcoal">-</dd>
</div>
<div>
<dt class="text-charcoal-300">Path</dt>
<dd id="detail-path" class="mt-1 font-mono text-xs text-charcoal">-</dd>
</div>
<div>
<dt class="text-charcoal-300">Created</dt>
<dd id="detail-created" class="mt-1 text-charcoal">-</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-charcoal-300">Tags</dt>
<dd id="detail-tags" class="mt-1 flex flex-wrap gap-2">-</dd>
</div>
</dl>
</div>
<!-- Access Keys -->
<div>
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-semibold text-charcoal">Access Keys</h3>
<p class="text-xs text-charcoal-300 mt-1">A user can hold 2 access keys. The server generates both halves of the pair.</p>
</div>
<button id="create-key-btn" onclick="createAccessKey()" class="px-3 py-1.5 text-xs bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">Create Access Key</button>
</div>
<div class="border border-gray-100 rounded-lg overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="text-left py-3 px-4 text-xs font-semibold text-charcoal">Access Key ID</th>
<th class="text-left py-3 px-4 text-xs font-semibold text-charcoal">Status</th>
<th class="text-left py-3 px-4 text-xs font-semibold text-charcoal">Created</th>
<th class="text-left py-3 px-4 text-xs font-semibold text-charcoal">Last Used</th>
<th class="text-right py-3 px-4 text-xs font-semibold text-charcoal">Actions</th>
</tr>
</thead>
<tbody id="keys-table-body"></tbody>
</table>
</div>
</div>
<!-- Inline Policies -->
<div>
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-semibold text-charcoal">Inline Policies</h3>
<p id="policy-quota-note" class="text-xs text-charcoal-300 mt-1">0 / 2048 bytes used</p>
</div>
<button onclick="openUserPolicyEditor()" class="px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 font-medium rounded-lg transition-colors">Add Policy</button>
</div>
<div class="border border-gray-100 rounded-lg overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="text-left py-3 px-4 text-xs font-semibold text-charcoal">Policy Name</th>
<th class="text-left py-3 px-4 text-xs font-semibold text-charcoal">Size</th>
<th class="text-right py-3 px-4 text-xs font-semibold text-charcoal">Actions</th>
</tr>
</thead>
<tbody id="policies-table-body"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between p-6 border-t border-gray-100 flex-shrink-0">
<button onclick="openEditUserModal()" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Rename or Move</button>
<button onclick="closeModal('manage-user-modal')" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">Done</button>
</div>
</div>
</div>
</div>
<!-- One-time Secret Modal -->
<div id="secret-modal" class="modal hidden fixed inset-0 z-50">
<div class="modal-backdrop absolute inset-0"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg relative">
<div class="flex items-center justify-between p-6 border-b border-gray-100">
<h2 class="text-xl font-semibold text-charcoal">Access Key Created</h2>
</div>
<div class="p-6 space-y-5">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<p class="text-sm text-yellow-800">This is the only time this secret will be shown. Copy it now — it cannot be retrieved later.</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Access Key ID</label>
<div class="flex gap-2">
<input type="text" id="new-key-id" readonly class="flex-1 px-4 py-2.5 border-2 border-gray-200 rounded-lg bg-gray-50 text-charcoal font-mono text-sm focus:outline-none">
<button type="button" onclick="iamCopy(document.getElementById('new-key-id').value, 'Access key ID')" class="px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-charcoal font-medium rounded-lg transition-colors text-sm">Copy</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Secret Access Key</label>
<div class="flex gap-2">
<div class="relative flex-1">
<input type="password" id="new-key-secret" readonly class="w-full px-4 py-2.5 pr-12 border-2 border-gray-200 rounded-lg bg-gray-50 text-charcoal font-mono text-sm focus:outline-none">
<button type="button" onclick="toggleSecretVisibility()" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
<svg id="secret-eye-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
<svg id="secret-eye-off-icon" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
<button type="button" onclick="copyNewSecret()" class="px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-charcoal font-medium rounded-lg transition-colors text-sm">Copy</button>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
<button onclick="closeSecretModal()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">I Have Copied the Secret</button>
</div>
</div>
</div>
</div>
<!-- Rename / Move Modal -->
<div id="edit-user-modal" class="modal hidden fixed inset-0 z-50">
<div class="modal-backdrop absolute inset-0" onclick="closeModal('edit-user-modal')"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg relative">
<div class="flex items-center justify-between p-6 border-b border-gray-100">
<h2 class="text-xl font-semibold text-charcoal">Rename or Move User</h2>
<button onclick="closeModal('edit-user-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-6 space-y-5">
<div>
<label class="block text-sm font-medium text-charcoal mb-2">New User Name</label>
<input type="text" id="edit-user-name" maxlength="64" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">New Path</label>
<input type="text" id="edit-user-path" maxlength="512" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
</div>
<p class="text-xs text-charcoal-300">Only changed fields are sent. Renaming or moving a user requires policy on both the old and the new ARN.</p>
</div>
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
<button onclick="closeModal('edit-user-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button id="edit-user-btn" onclick="submitEditUser()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">Save Changes</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-user-modal" class="modal hidden fixed inset-0 z-50">
<div class="modal-backdrop absolute inset-0" onclick="closeModal('delete-user-modal')"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
<div class="p-6">
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-charcoal text-center mb-2">Delete IAM User</h3>
<p class="text-charcoal-300 text-center mb-4">
Are you sure you want to delete <span id="delete-user-name" class="font-mono text-charcoal"></span>? This action cannot be undone.
</p>
<p class="text-charcoal-300 text-center text-sm mb-6">Access keys and inline policies must be removed first. There is no cascade delete.</p>
<div class="flex items-center justify-center gap-3">
<button onclick="closeModal('delete-user-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button id="confirm-delete-user-btn" onclick="confirmDeleteUser()" class="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">Delete User</button>
</div>
</div>
</div>
</div>
</div>
<script>
let allUsers = [];
let nextMarker = null;
let currentUser = null; // the user open in the manage modal
let policySizes = {}; // policyName -> byte length, for the aggregate quota
let activeKeyCount = 0;
let userToDelete = null;
// The generated secret lives here and nowhere else: never sessionStorage,
// never a data-* attribute. Discarded when the reveal panel closes.
let revealedSecret = null;
if (!requireIAM()) {
// Redirected
} else {
initSidebarWithRole();
updateUserInfo();
loadUsers();
openRequestedUser();
}
/**
* Open one user's manage view straight from ?user=<name>. A caller scoped
* to their own ARN cannot list users, so this is their only route to
* their own record; everything behind it is per-action authorized anyway.
*/
function openRequestedUser() {
const requested = new URLSearchParams(window.location.search).get('user');
if (requested) openManageUserModal(requested);
}
// ============================================
// List
// ============================================
async function loadUsers() {
showTableLoading('users-table-body', 5);
allUsers = [];
nextMarker = null;
await fetchUserPage();
}
async function loadMoreUsers() {
const btn = document.getElementById('load-more-btn');
setLoading(btn, true);
try {
await fetchUserPage();
} finally {
setLoading(btn, false);
}
}
async function fetchUserPage() {
const pathPrefix = document.getElementById('path-prefix-input').value.trim();
try {
const result = await api.iamListUsers({
pathPrefix: pathPrefix || undefined,
marker: nextMarker || undefined,
maxItems: IAM_LIMITS.listPageSize
});
allUsers = allUsers.concat(result.users);
nextMarker = result.isTruncated ? result.marker : null;
document.getElementById('load-more-row').classList.toggle('hidden', !nextMarker);
filterUsers();
} catch (error) {
console.error('Error loading IAM users:', error);
document.getElementById('load-more-row').classList.add('hidden');
if (iamIsAccessDenied(error)) {
iamShowAccessDenied('users-table-body', 5, 'You don\u2019t have permission to list IAM users');
offerOpenUserByName();
} else {
showToast(iamErrorText(error, 'loading IAM users'), 'error');
showEmptyState('users-table-body', 5, 'Error loading IAM users');
}
}
}
/**
* Append a name box to the access-denied state: listing users and acting
* on one are separate permissions, so a denied list says nothing about
* whether this caller can manage the user they came for.
*/
function offerOpenUserByName() {
const tbody = document.getElementById('users-table-body');
const row = document.createElement('tr');
row.innerHTML = `
<td colspan="5" class="pb-12 px-6 text-center">
<div class="inline-flex items-center gap-2">
<input id="open-user-name" type="text" placeholder="User name" maxlength="${IAM_LIMITS.nameChars}"
class="px-3 py-2 border-2 border-gray-200 rounded-lg text-sm text-charcoal focus:outline-none focus:border-accent">
<button onclick="openUserByName()" class="px-3 py-2 text-sm bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">Open user</button>
</div>
<p class="mt-3 text-xs text-charcoal-300">Open a user you can manage without listing them all.</p>
</td>`;
tbody.appendChild(row);
document.getElementById('open-user-name').addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); openUserByName(); }
});
}
function openUserByName() {
const name = document.getElementById('open-user-name').value.trim();
if (!name) return;
openManageUserModal(name);
}
function filterUsers() {
const term = document.getElementById('search-input').value.toLowerCase();
const filtered = term
? allUsers.filter(u => (u.UserName || '').toLowerCase().includes(term))
: allUsers;
renderUsers(filtered);
}
function renderUsers(users) {
const tbody = document.getElementById('users-table-body');
tbody.innerHTML = '';
if (users.length === 0) {
showEmptyState('users-table-body', 5, 'No IAM users found');
return;
}
users.forEach(user => {
const name = user.UserName || '';
const row = document.createElement('tr');
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
row.innerHTML = `
<td class="py-4 px-6"><span class="font-mono text-sm text-charcoal">${escapeHtml(user.Path || '/')}</span></td>
<td class="py-4 px-6"><span class="font-mono text-sm text-charcoal">${escapeHtml(name)}</span></td>
<td class="py-4 px-6">${iamArnCell(user.Arn)}</td>
<td class="py-4 px-6 text-sm text-charcoal">${escapeHtml(iamFormatDate(user.CreateDate))}</td>
<td class="py-4 px-6 text-right">
<div class="flex items-center justify-end gap-2">
<button onclick="openManageUserModal('${escapeHtml(name)}')" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal font-medium rounded-lg transition-colors">Manage</button>
<button onclick="openDeleteUserModal('${escapeHtml(name)}')" class="p-2 text-charcoal-300 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Delete">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>`;
tbody.appendChild(row);
});
}
// ============================================
// Create
// ============================================
function openCreateUserModal() {
document.getElementById('create-user-name').value = '';
document.getElementById('create-user-path').value = '';
document.getElementById('create-user-tags').innerHTML = '';
openModal('create-user-modal');
}
async function submitCreateUser() {
const userName = document.getElementById('create-user-name').value.trim();
const path = document.getElementById('create-user-path').value.trim();
const tags = iamCollectTags('create-user-tags');
const nameError = iamValidateName(userName, 'User name');
if (nameError) { showToast(nameError, 'error'); return; }
const pathError = iamValidatePath(path);
if (pathError) { showToast(pathError, 'error'); return; }
const btn = document.getElementById('create-user-btn');
setLoading(btn, true);
try {
await api.iamCreateUser(userName, path || undefined, tags);
showToast('IAM user created successfully', 'success');
closeModal('create-user-modal');
loadUsers();
} catch (error) {
console.error('Error creating IAM user:', error);
showToast(iamErrorText(error, 'creating IAM user'), 'error');
} finally {
setLoading(btn, false);
}
}
// ============================================
// Manage: details, access keys, inline policies
// ============================================
async function openManageUserModal(userName) {
currentUser = allUsers.find(u => u.UserName === userName);
if (!currentUser) {
// Reached by deep link or by name, so there is no cached record. GetUser
// is its own permission: a denial just leaves the detail fields blank,
// it does not stop the access-key and policy panels from loading.
try {
currentUser = await api.iamGetUser(userName);
} catch (error) {
currentUser = { UserName: userName };
}
}
policySizes = {};
activeKeyCount = 0;
document.getElementById('manage-user-title').textContent = userName;
document.getElementById('manage-user-subtitle').textContent = 'IAM user';
document.getElementById('detail-arn').innerHTML = iamArnCell(currentUser.Arn);
document.getElementById('detail-userid').textContent = currentUser.UserId || '-';
document.getElementById('detail-path').textContent = currentUser.Path || '/';
document.getElementById('detail-created').textContent = iamFormatDate(currentUser.CreateDate);
renderTags(currentUser.Tags);
openModal('manage-user-modal');
loadAccessKeys();
loadUserPolicies();
}
function renderTags(tags) {
const el = document.getElementById('detail-tags');
const list = Array.isArray(tags) ? tags : (tags ? [tags] : []);
if (list.length === 0) {
el.innerHTML = '<span class="text-charcoal-300">-</span>';
return;
}
el.innerHTML = list.map(tag =>
`<span class="px-2 py-0.5 bg-gray-100 text-charcoal text-xs font-mono rounded">${escapeHtml(tag.Key)}=${escapeHtml(tag.Value || '')}</span>`
).join('');
}
async function loadAccessKeys() {
const tbody = document.getElementById('keys-table-body');
showTableLoading('keys-table-body', 5);
try {
const { keys } = await api.iamListAccessKeys(currentUser.UserName);
activeKeyCount = keys.length;
updateCreateKeyButton();
if (keys.length === 0) {
showEmptyState('keys-table-body', 5, 'No access keys');
return;
}
tbody.innerHTML = '';
keys.forEach(key => {
const id = key.AccessKeyId || '';
const nextStatus = key.Status === 'Active' ? 'Inactive' : 'Active';
const row = document.createElement('tr');
row.className = 'border-b border-gray-50';
row.innerHTML = `
<td class="py-3 px-4"><span class="font-mono text-xs text-charcoal">${escapeHtml(id)}</span></td>
<td class="py-3 px-4">${iamStatusBadge(key.Status)}</td>
<td class="py-3 px-4 text-xs text-charcoal">${escapeHtml(iamFormatDate(key.CreateDate))}</td>
<td class="py-3 px-4 text-xs text-charcoal" id="last-used-${escapeHtml(id)}">
<button onclick="fetchLastUsed('${escapeHtml(id)}')" class="text-accent hover:underline">Check</button>
</td>
<td class="py-3 px-4 text-right">
<div class="flex items-center justify-end gap-2">
<button onclick="setKeyStatus('${escapeHtml(id)}', '${nextStatus}')" class="px-2.5 py-1 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Make ${nextStatus}</button>
<button onclick="deleteAccessKey('${escapeHtml(id)}')" class="p-1.5 text-charcoal-300 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Delete">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>`;
tbody.appendChild(row);
});
} catch (error) {
console.error('Error loading access keys:', error);
if (iamIsAccessDenied(error)) {
iamShowAccessDenied('keys-table-body', 5, 'You don\u2019t have permission to list this user\u2019s access keys');
} else {
showEmptyState('keys-table-body', 5, 'Error loading access keys: ' + iamShortError(error));
}
}
}
function updateCreateKeyButton() {
const btn = document.getElementById('create-key-btn');
const atQuota = activeKeyCount >= IAM_LIMITS.accessKeysPerUser;
btn.disabled = atQuota;
btn.className = atQuota
? 'px-3 py-1.5 text-xs bg-accent text-white font-medium rounded-lg opacity-50 cursor-not-allowed'
: 'px-3 py-1.5 text-xs bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors';
btn.title = atQuota ? `A user can hold ${IAM_LIMITS.accessKeysPerUser} access keys. Delete one first.` : '';
}
async function fetchLastUsed(accessKeyId) {
const cell = document.getElementById('last-used-' + accessKeyId);
cell.textContent = 'Loading...';
try {
const { lastUsed } = await api.iamGetAccessKeyLastUsed(accessKeyId);
const never = !lastUsed.LastUsedDate || lastUsed.ServiceName === 'N/A';
cell.textContent = never
? 'Never used'
: `${iamFormatDate(lastUsed.LastUsedDate)} (${lastUsed.ServiceName})`;
} catch (error) {
cell.textContent = iamIsAccessDenied(error) ? 'No permission' : 'Unavailable';
}
}
async function setKeyStatus(accessKeyId, status) {
try {
await api.iamUpdateAccessKey(currentUser.UserName, accessKeyId, status);
showToast('Access key updated successfully', 'success');
loadAccessKeys();
} catch (error) {
showToast(iamErrorText(error, 'updating access key'), 'error');
}
}
function deleteAccessKey(accessKeyId) {
confirm(`Delete access key ${accessKeyId}? This action cannot be undone.`, async () => {
try {
await api.iamDeleteAccessKey(currentUser.UserName, accessKeyId);
showToast('Access key deleted successfully', 'success');
loadAccessKeys();
} catch (error) {
showToast(iamErrorText(error, 'deleting access key'), 'error');
}
});
}
async function createAccessKey() {
const btn = document.getElementById('create-key-btn');
setLoading(btn, true);
try {
const key = await api.iamCreateAccessKey(currentUser.UserName);
revealedSecret = key.SecretAccessKey || '';
document.getElementById('new-key-id').value = key.AccessKeyId || '';
const secretInput = document.getElementById('new-key-secret');
secretInput.type = 'password';
secretInput.value = revealedSecret;
document.getElementById('secret-eye-icon').classList.remove('hidden');
document.getElementById('secret-eye-off-icon').classList.add('hidden');
openModal('secret-modal');
loadAccessKeys();
} catch (error) {
console.error('Error creating access key:', error);
showToast(iamErrorText(error, 'creating access key'), 'error');
} finally {
setLoading(btn, false);
updateCreateKeyButton();
}
}
function toggleSecretVisibility() {
const input = document.getElementById('new-key-secret');
const eye = document.getElementById('secret-eye-icon');
const eyeOff = document.getElementById('secret-eye-off-icon');
if (input.type === 'password') {
input.type = 'text';
eye.classList.add('hidden');
eyeOff.classList.remove('hidden');
} else {
input.type = 'password';
eye.classList.remove('hidden');
eyeOff.classList.add('hidden');
}
}
function copyNewSecret() {
if (!revealedSecret) return;
iamCopy(revealedSecret, 'Secret access key');
}
function closeSecretModal() {
// Discard the secret so the panel cannot be reopened with the same value
revealedSecret = null;
document.getElementById('new-key-secret').value = '';
document.getElementById('new-key-id').value = '';
closeModal('secret-modal');
}
async function loadUserPolicies() {
const tbody = document.getElementById('policies-table-body');
showTableLoading('policies-table-body', 3);
policySizes = {};
try {
const { policyNames } = await api.iamListUserPolicies(currentUser.UserName);
if (policyNames.length === 0) {
showEmptyState('policies-table-body', 3, 'No inline policies');
updatePolicyQuotaNote();
return;
}
// Fetch each document so the aggregate byte counter is accurate
await Promise.all(policyNames.map(async name => {
try {
const policy = await api.iamGetUserPolicy(currentUser.UserName, name);
policySizes[name] = iamByteLength(policy.policyDocument);
} catch (error) {
policySizes[name] = null;
}
}));
tbody.innerHTML = '';
policyNames.forEach(name => {
const size = policySizes[name];
const row = document.createElement('tr');
row.className = 'border-b border-gray-50';
row.innerHTML = `
<td class="py-3 px-4"><span class="font-mono text-xs text-charcoal">${escapeHtml(name)}</span></td>
<td class="py-3 px-4 text-xs text-charcoal">${size === null ? '-' : size + ' bytes'}</td>
<td class="py-3 px-4 text-right">
<div class="flex items-center justify-end gap-2">
<button onclick="openUserPolicyEditor('${escapeHtml(name)}')" class="px-2.5 py-1 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Edit</button>
<button onclick="deleteUserPolicy('${escapeHtml(name)}')" class="p-1.5 text-charcoal-300 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Delete">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>`;
tbody.appendChild(row);
});
updatePolicyQuotaNote();
} catch (error) {
console.error('Error loading inline policies:', error);
if (iamIsAccessDenied(error)) {
iamShowAccessDenied('policies-table-body', 3, 'You don\u2019t have permission to list this user\u2019s inline policies');
} else {
showEmptyState('policies-table-body', 3, 'Error loading inline policies: ' + iamShortError(error));
}
}
}
function totalPolicyBytes(excludeName) {
return Object.entries(policySizes).reduce((sum, [name, size]) => {
if (name === excludeName || size === null) return sum;
return sum + size;
}, 0);
}
function updatePolicyQuotaNote() {
const used = totalPolicyBytes();
const note = document.getElementById('policy-quota-note');
note.textContent = `${used} / ${IAM_LIMITS.userPolicyBytes} bytes used across this user's inline policies`;
note.className = used > IAM_LIMITS.userPolicyBytes ? 'text-xs text-red-600 font-medium mt-1' : 'text-xs text-charcoal-300 mt-1';
}
async function openUserPolicyEditor(policyName) {
const isNew = !policyName;
let documentText = '';
if (!isNew) {
try {
const policy = await api.iamGetUserPolicy(currentUser.UserName, policyName);
documentText = policy.policyDocument;
try {
documentText = JSON.stringify(JSON.parse(documentText), null, 2);
} catch (e) {
// Leave the server's text as-is if it is not valid JSON
}
} catch (error) {
showToast(iamErrorText(error, 'loading policy'), 'error');
return;
}
}
iamPolicyEditor.open({
variant: 'identity',
title: isNew ? 'Add Inline Policy' : 'Inline Policy',
subtitle: `User ${currentUser.UserName}${isNew ? '' : ' \u2014 ' + policyName}`,
policyName: policyName || '',
nameEditable: isNew,
document: documentText,
quota: { otherBytes: totalPolicyBytes(policyName), max: IAM_LIMITS.userPolicyBytes },
maxBytes: IAM_LIMITS.policyDocumentBytes,
showDelete: !isNew,
onSave: async ({ policyName: name, document: doc }) => {
await api.iamPutUserPolicy(currentUser.UserName, name, doc);
showToast('Policy saved successfully', 'success');
loadUserPolicies();
},
onDelete: async () => {
await api.iamDeleteUserPolicy(currentUser.UserName, policyName);
showToast('Policy deleted successfully', 'success');
loadUserPolicies();
}
});
}
function deleteUserPolicy(policyName) {
confirm(`Delete inline policy ${policyName}? This action cannot be undone.`, async () => {
try {
await api.iamDeleteUserPolicy(currentUser.UserName, policyName);
showToast('Policy deleted successfully', 'success');
loadUserPolicies();
} catch (error) {
showToast(iamErrorText(error, 'deleting policy'), 'error');
}
});
}
// ============================================
// Rename / move and delete
// ============================================
function openEditUserModal() {
if (!currentUser) return;
document.getElementById('edit-user-name').value = currentUser.UserName || '';
document.getElementById('edit-user-path').value = currentUser.Path || '/';
openModal('edit-user-modal');
}
async function submitEditUser() {
const newName = document.getElementById('edit-user-name').value.trim();
const newPath = document.getElementById('edit-user-path').value.trim();
if (newName && newName !== currentUser.UserName) {
const nameError = iamValidateName(newName, 'User name');
if (nameError) { showToast(nameError, 'error'); return; }
}
const pathError = iamValidatePath(newPath);
if (pathError) { showToast(pathError, 'error'); return; }
const changedName = newName && newName !== currentUser.UserName ? newName : undefined;
const changedPath = newPath && newPath !== (currentUser.Path || '/') ? newPath : undefined;
if (!changedName && !changedPath) {
showToast('Nothing to update', 'info');
return;
}
const btn = document.getElementById('edit-user-btn');
setLoading(btn, true);
try {
await api.iamUpdateUser(currentUser.UserName, changedName, changedPath);
showToast('IAM user updated successfully', 'success');
closeModal('edit-user-modal');
closeModal('manage-user-modal');
loadUsers();
} catch (error) {
showToast(iamErrorText(error, 'updating IAM user'), 'error');
} finally {
setLoading(btn, false);
}
}
function openDeleteUserModal(userName) {
userToDelete = userName;
document.getElementById('delete-user-name').textContent = userName;
openModal('delete-user-modal');
}
async function confirmDeleteUser() {
if (!userToDelete) return;
const btn = document.getElementById('confirm-delete-user-btn');
setLoading(btn, true);
try {
await api.iamDeleteUser(userToDelete);
showToast('IAM user deleted successfully', 'success');
closeModal('delete-user-modal');
userToDelete = null;
loadUsers();
} catch (error) {
console.error('Error deleting IAM user:', error);
showToast(iamErrorText(error, 'deleting IAM user'), 'error');
} finally {
setLoading(btn, false);
}
}
</script>
</body>
</html>
+384
View File
@@ -0,0 +1,384 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VersityGW Admin - IAM</title>
<script src="assets/js/crypto-js.min.js"></script>
<script src="assets/js/tailwind.js"></script>
<script src="assets/css/tailwind-config.js"></script>
<link rel="stylesheet" href="assets/css/fonts.css">
<link rel="stylesheet" href="assets/css/theme.css">
<link rel="icon" type="image/png" href="assets/images/favicon.png">
</head>
<body class="min-h-screen bg-surface">
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<script src="js/iam-ui.js"></script>
<div class="relative flex h-screen overflow-hidden">
<input id="sidebar-toggle" type="checkbox" class="peer hidden"/>
<label for="sidebar-toggle" aria-label="Toggle navigation" class="
sm:hidden rotate-180 peer-checked:rotate-0 absolute z-20 top-[14px] left-6
flex justify-center items-center p-2 rounded-lg transition-all
text-charcoal-300 hover:text-charcoal hover:bg-gray-100
peer-checked:text-white/70 peer-checked:hover:text-white peer-checked:hover:bg-white/10
">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="-0.5 0 25 25">
<path stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="M7.6728 22L16.1434 13.0294C16.4081 12.75 16.4081 12.3088 16.1434 12.0147L7.65808 3" />
</svg>
</label>
<!-- Sidebar -->
<aside class="absolute z-10 sm:static -translate-x-60 peer-checked:translate-x-0 sm:!translate-x-0 w-60 h-screen bg-charcoal flex flex-col overflow-auto transition-all">
<div class="ml-12 sm:ml-0 h-16 flex-shrink-0 flex items-center px-6 border-b border-white/10">
<a href="https://www.versity.com" target="_blank" rel="noopener noreferrer">
<img src="assets/images/Versity-logo-white-horizontal.png" alt="Versity" class="h-10 hover:opacity-80 transition-opacity">
</a>
</div>
<nav class="flex-1 py-4">
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-management-only>
Management
</div>
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
<span class="font-medium">Dashboard</span>
</a>
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only data-admin-users-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<span class="font-medium">Users</span>
</a>
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
<span class="font-medium">Buckets</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-management-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-s3-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span class="font-medium">Explorer</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-s3-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-iam-only>
Identity &amp; Access
</div>
<a href="iam.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-iam-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span class="font-medium">IAM</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-iam-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
Resources
</div>
<a href="https://github.com/versity/versitygw/wiki" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<span class="font-medium">Documentation</span>
</a>
<a href="https://github.com/versity/versitygw" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
<span class="font-medium">GitHub</span>
</a>
</nav>
<div class="p-4 border-t border-white/10">
<div id="user-info" class="flex items-center gap-3 mb-3"></div>
<button onclick="api.logout(); window.location.href='index.html';" class="w-full flex items-center gap-2 px-3 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors text-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
Sign Out
</button>
</div>
</aside>
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-hidden">
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0">
<h1 class="ml-12 sm:ml-0 text-xl font-semibold text-charcoal">VersityGW IAM</h1>
<button onclick="loadIamOverview()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</header>
<main class="flex-1 overflow-auto p-6">
<div class="max-w-7xl mx-auto">
<!-- Page Header -->
<div class="flex flex-wrap items-start justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-semibold text-charcoal">Identity &amp; Access</h1>
<p class="text-charcoal-300 mt-1">Manage IAM users, roles, and identity providers</p>
</div>
<div class="text-right">
<p class="text-xs font-semibold tracking-wider text-charcoal-300 uppercase">IAM API Endpoint</p>
<p id="iam-endpoint-display" class="font-mono text-sm text-charcoal mt-1">-</p>
</div>
</div>
<!-- Stat Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-charcoal-300">IAM Users</p>
<p id="stat-users" class="text-3xl font-bold text-charcoal mt-2">-</p>
<p id="stat-users-note" class="text-xs text-charcoal-300 mt-2"></p>
</div>
<div class="w-12 h-12 bg-primary-50 rounded-lg flex items-center justify-center">
<svg class="w-7 h-7 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-charcoal-300">Roles</p>
<p id="stat-roles" class="text-3xl font-bold text-charcoal mt-2">-</p>
<p id="stat-roles-note" class="text-xs text-charcoal-300 mt-2"></p>
</div>
<div class="w-12 h-12 bg-accent-50 rounded-lg flex items-center justify-center">
<svg class="w-7 h-7 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-charcoal-300">OIDC Providers</p>
<p id="stat-oidc" class="text-3xl font-bold text-charcoal mt-2">-</p>
<p id="stat-oidc-note" class="text-xs text-charcoal-300 mt-2"></p>
</div>
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
<svg class="w-7 h-7 text-charcoal" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m0 0a9 9 0 019 9"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Two Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- My Identity -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-charcoal">My Identity</h3>
<span id="identity-type-badge" class="px-2.5 py-1 bg-gray-100 text-charcoal text-xs font-medium rounded-md">-</span>
</div>
<div class="space-y-4">
<div class="flex items-start justify-between gap-4 py-3 border-b border-gray-100">
<span class="text-charcoal-300 flex-shrink-0">ARN</span>
<div id="identity-arn" class="min-w-0">-</div>
</div>
<div class="flex items-center justify-between gap-4 py-3 border-b border-gray-100">
<span class="text-charcoal-300">User ID</span>
<span id="identity-userid" class="text-charcoal font-mono text-sm truncate">-</span>
</div>
<div class="flex items-center justify-between gap-4 py-3 border-b border-gray-100">
<span class="text-charcoal-300">Account</span>
<span id="identity-account" class="text-charcoal font-mono text-sm">-</span>
</div>
<div id="identity-user-row" class="hidden flex items-center justify-between gap-4 py-3 border-b border-gray-100">
<span class="text-charcoal-300">Path</span>
<span id="identity-path" class="text-charcoal font-mono text-sm">-</span>
</div>
<div id="identity-created-row" class="hidden flex items-center justify-between gap-4 py-3 border-b border-gray-100">
<span class="text-charcoal-300">Created</span>
<span id="identity-created" class="text-charcoal text-sm">-</span>
</div>
<div id="identity-keys-row" class="hidden flex items-center justify-between gap-4 py-3">
<span class="text-charcoal-300">Access Keys</span>
<span id="identity-keys" class="text-charcoal text-sm">-</span>
</div>
</div>
<a id="identity-manage-link" href="#" class="hidden mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-accent hover:text-accent-600 transition-colors">
Manage my access keys and policies
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
<p id="identity-note" class="text-xs text-charcoal-300 mt-4"></p>
</div>
<!-- Manage -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<h3 class="text-lg font-semibold text-charcoal mb-4">Manage</h3>
<div class="space-y-3">
<a href="iam-users.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group">
<div class="w-10 h-10 bg-primary-50 rounded-lg flex items-center justify-center group-hover:bg-primary-100 transition-colors">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
</div>
<div>
<p class="font-medium text-charcoal">IAM Users</p>
<p class="text-sm text-charcoal-300">Users, access keys, and inline policies</p>
</div>
<svg class="w-5 h-5 text-charcoal-300 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
<a href="iam-roles.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group">
<div class="w-10 h-10 bg-accent-50 rounded-lg flex items-center justify-center group-hover:bg-accent-100 transition-colors">
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
</div>
<div>
<p class="font-medium text-charcoal">Roles</p>
<p class="text-sm text-charcoal-300">Trust policies and inline policies</p>
</div>
<svg class="w-5 h-5 text-charcoal-300 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
<a href="iam-oidc.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group">
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center group-hover:bg-gray-200 transition-colors">
<svg class="w-5 h-5 text-charcoal" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m0 0a9 9 0 019 9"/>
</svg>
</div>
<div>
<p class="font-medium text-charcoal">OIDC Providers</p>
<p class="text-sm text-charcoal-300">Identity providers, client IDs, thumbprints</p>
</div>
<svg class="w-5 h-5 text-charcoal-300 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
if (!requireIAM()) {
// Redirected
} else {
initSidebarWithRole();
updateUserInfo();
loadIamOverview();
}
function loadIamOverview() {
const info = api.getCredentialsInfo();
document.getElementById('iam-endpoint-display').textContent = (info && info.iamEndpoint) || '-';
// Every call below is attempted independently: a denied List* reports in
// its own card instead of failing the page.
loadCallerIdentity();
loadStat('users', () => api.iamListUsers({ maxItems: IAM_LIMITS.listPageSize }), r => ({ count: r.users.length, truncated: r.isTruncated }), 'list users');
loadStat('roles', () => api.iamListRoles({ maxItems: IAM_LIMITS.listPageSize }), r => ({ count: r.roles.length, truncated: r.isTruncated }), 'list roles');
loadStat('oidc', () => api.iamListOIDCProviders(), r => ({ count: r.length, truncated: false }), 'list identity providers');
}
async function loadStat(key, call, extract, action) {
const value = document.getElementById('stat-' + key);
const note = document.getElementById('stat-' + key + '-note');
value.textContent = '-';
note.textContent = '';
try {
const { count, truncated } = extract(await call());
value.textContent = truncated ? count + '+' : String(count);
if (truncated) note.textContent = 'First page only';
} catch (error) {
value.textContent = '-';
note.textContent = iamIsAccessDenied(error)
? 'You don\u2019t have permission to ' + action
: iamShortError(error);
}
}
async function loadCallerIdentity() {
const badge = document.getElementById('identity-type-badge');
const note = document.getElementById('identity-note');
note.textContent = '';
let identity;
try {
identity = await api.iamGetCallerIdentity();
} catch (error) {
badge.textContent = 'Unknown';
document.getElementById('identity-arn').innerHTML = '<span class="text-charcoal-300">-</span>';
note.textContent = 'Error loading identity: ' + iamShortError(error);
return;
}
const isRoot = identity.arn.endsWith(':root');
badge.textContent = isRoot ? 'Root' : 'IAM User';
badge.className = 'px-2.5 py-1 text-xs font-medium rounded-md ' + (isRoot ? 'bg-primary-50 text-primary' : 'bg-accent-50 text-accent');
document.getElementById('identity-arn').innerHTML = iamArnCell(identity.arn);
document.getElementById('identity-userid').textContent = identity.userId || '-';
document.getElementById('identity-account').textContent = identity.account || IAM_ACCOUNT_ID;
if (isRoot) {
note.textContent = 'The root credential bypasses IAM policy checks entirely.';
return;
}
// Route to this caller's own record by name. Listing users is a separate
// permission a self-service-only identity will not hold, so the IAM Users
// table may never render for them - this link does not go through it.
const manageLink = document.getElementById('identity-manage-link');
manageLink.href = 'iam-users.html?user=' + encodeURIComponent(identity.arn.split('/').pop());
manageLink.classList.remove('hidden');
// Opportunistic enrichment: both calls need policy grants, so a denial
// just hides that part of the card.
let userName = identity.arn.split('/').pop();
try {
const user = await api.iamGetUser();
userName = user.UserName || userName;
document.getElementById('identity-path').textContent = user.Path || '/';
document.getElementById('identity-user-row').classList.remove('hidden');
document.getElementById('identity-created').textContent = iamFormatDate(user.CreateDate);
document.getElementById('identity-created-row').classList.remove('hidden');
} catch (error) {
note.textContent = 'Add iam:GetUser on your own ARN to see your user record here.';
}
try {
const { keys } = await api.iamListAccessKeys(userName);
const active = keys.filter(k => k.Status === 'Active').length;
document.getElementById('identity-keys').textContent = `${keys.length} of ${IAM_LIMITS.accessKeysPerUser} (${active} active)`;
document.getElementById('identity-keys-row').classList.remove('hidden');
} catch (error) {
// Denied or unknown user name: leave the row hidden
}
}
</script>
</body>
</html>
+171 -33
View File
@@ -107,13 +107,12 @@ under the License.
<!-- Advanced Options Section -->
<div id="advanced-options-section" class="advanced-options space-y-5">
<!-- S3 Endpoint URL -->
<div>
<div id="s3-endpoint-field">
<label class="block text-sm font-medium text-charcoal-400 mb-2">S3 API Endpoint</label>
<div class="relative" id="endpoint-container">
<input
type="url"
id="endpoint-select"
required
placeholder="http://localhost:7070"
autocomplete="off"
class="w-full px-4 py-3 pr-10 border-2 border-gray-200 rounded-lg text-charcoal placeholder:text-gray-400 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
@@ -130,13 +129,12 @@ under the License.
</div>
<!-- Admin Endpoint URL -->
<div>
<div id="admin-endpoint-field">
<label class="block text-sm font-medium text-charcoal-400 mb-2">Admin API Endpoint</label>
<div class="relative" id="admin-endpoint-container">
<input
type="url"
id="admin-endpoint-select"
required
placeholder="http://localhost:7070"
autocomplete="off"
class="w-full px-4 py-3 pr-10 border-2 border-gray-200 rounded-lg text-charcoal placeholder:text-gray-400 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
@@ -152,8 +150,31 @@ under the License.
</div>
</div>
<!-- IAM Endpoint URL (optional) -->
<div id="iam-endpoint-field">
<label class="block text-sm font-medium text-charcoal-400 mb-2">IAM API Endpoint</label>
<div class="relative" id="iam-endpoint-container">
<input
type="url"
id="iam-endpoint-select"
placeholder="http://localhost:7076"
autocomplete="off"
class="w-full px-4 py-3 pr-10 border-2 border-gray-200 rounded-lg text-charcoal placeholder:text-gray-400 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
>
<button type="button" onclick="toggleDropdown('iam-endpoint')" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="iam-endpoint-dropdown" class="custom-dropdown">
<!-- Populated dynamically -->
</div>
</div>
<p class="mt-2 text-xs text-charcoal-300">Optional. Set this to the standalone IAM service to manage IAM users, roles and identity providers. When set, the IAM service is the user-management system and the Admin API is not used. Leave the S3 endpoint blank to sign in to IAM alone.</p>
</div>
<!-- Region Selector -->
<div>
<div id="region-field">
<label class="block text-sm font-medium text-charcoal-400 mb-2">Region</label>
<div class="relative" id="region-container">
<input
@@ -178,7 +199,7 @@ under the License.
</div>
<!-- Bucket Addressing Style -->
<div>
<div id="addressing-style-field">
<label class="block text-sm font-medium text-charcoal-400 mb-2">Bucket Addressing Style</label>
<div class="flex items-center justify-between">
<span class="toggle-label">Path Style</span>
@@ -216,12 +237,41 @@ under the License.
// ============================================
// Advanced Options Toggle
// ============================================
let advancedOptionsTimer = null;
function toggleAdvancedOptions() {
const toggle = document.getElementById('advanced-options-toggle');
const section = document.getElementById('advanced-options-section');
toggle.classList.toggle('expanded');
section.classList.toggle('show');
const opening = !toggle.classList.contains('expanded');
toggle.classList.toggle('expanded', opening);
clearTimeout(advancedOptionsTimer);
// Both directions animate between 0 and a height measured here rather
// than a number named in the stylesheet, which clipped the last field
// once the fields outgrew it. Opening measures the content, closing
// measures what is on screen (possibly a part-grown section), and
// pinning the height gives a close somewhere to animate from.
const pinned = opening ? section.scrollHeight : section.getBoundingClientRect().height;
section.style.maxHeight = pinned + 'px';
section.style.overflow = '';
if (!opening) {
// Force the pinned height to take effect, so releasing it below is a
// change the browser can animate rather than one it never sees.
void section.offsetHeight;
section.style.maxHeight = '';
return;
}
advancedOptionsTimer = setTimeout(() => {
// A finished section keeps no cap, so it resizes with whichever
// fields are shown and lets dropdowns hang past its bottom edge.
// Inline rather than a class, so a browser holding a stale stylesheet
// cannot leave the section stuck shut.
section.style.maxHeight = 'none';
section.style.overflow = 'visible';
}, parseFloat(getComputedStyle(section).transitionDuration) * 1000);
}
// ============================================
@@ -229,6 +279,7 @@ under the License.
// ============================================
let configuredGateways = [];
let configuredAdminGateways = [];
let configuredIamGateways = [];
let configuredDefaultRegion = null;
function normalizeEndpoint(value) {
@@ -256,10 +307,15 @@ under the License.
function loadConfiguredGateways() {
const cfg = window.__VGWCONFIG__ || {};
if (!Array.isArray(cfg.gateways)) return { gateways: [], adminGateways: [], defaultRegion: null };
// Each list is read on its own. The IAM service serves this page with no
// S3 gateways at all, so an absent gateways list is a real configuration
// and must not discard the IAM list injected alongside it.
const list = (value) => (Array.isArray(value) ? value : []);
const gateways = list(cfg.gateways);
return {
gateways: cfg.gateways,
adminGateways: cfg.adminGateways || cfg.gateways || [],
gateways,
adminGateways: list(cfg.adminGateways).length > 0 ? list(cfg.adminGateways) : gateways,
iamGateways: list(cfg.iamGateways),
defaultRegion: normalizeRegion(typeof cfg.defaultRegion === 'string' ? cfg.defaultRegion : null),
};
}
@@ -268,6 +324,7 @@ under the License.
const cfg = loadConfiguredGateways();
configuredGateways = uniqNonEmpty(cfg.gateways);
configuredAdminGateways = uniqNonEmpty(cfg.adminGateways);
configuredIamGateways = uniqNonEmpty(cfg.iamGateways);
configuredDefaultRegion = cfg.defaultRegion;
// Apply default region from server only if user hasn't changed it yet
@@ -298,6 +355,17 @@ under the License.
adminEndpointInput.value = configuredAdminGateways[0];
onAdminEndpointInput(configuredAdminGateways[0]);
}
// Default the iam-endpoint input to the first configured IAM gateway (if any).
// There is no fallback to the S3/admin gateways: the IAM service runs as a
// separate process, so an unset flag means "no IAM endpoint", not "same host".
const iamEndpointInput = document.getElementById('iam-endpoint-select');
if (configuredIamGateways.length > 0 && iamEndpointInput && !iamEndpointInput.value.trim()) {
iamEndpointInput.value = configuredIamGateways[0];
onIamEndpointInput(configuredIamGateways[0]);
}
applyEndpointVisibility();
}
// ============================================
@@ -362,11 +430,15 @@ under the License.
if (name === 'admin-endpoint' && dropdown.classList.contains('show')) {
populateAdminEndpointDropdown();
}
// If opening iam-endpoint dropdown, populate it
if (name === 'iam-endpoint' && dropdown.classList.contains('show')) {
populateIamEndpointDropdown();
}
}
// Close all dropdowns when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('#endpoint-container') && !e.target.closest('#admin-endpoint-container') && !e.target.closest('#region-container')) {
if (!e.target.closest('#endpoint-container') && !e.target.closest('#admin-endpoint-container') && !e.target.closest('#iam-endpoint-container') && !e.target.closest('#region-container')) {
document.querySelectorAll('.custom-dropdown').forEach(d => d.classList.remove('show'));
}
});
@@ -435,6 +507,34 @@ under the License.
onAdminEndpointInput(endpoint);
}
// Populate iam-endpoint dropdown (with configured IAM gateways)
function populateIamEndpointDropdown() {
const dropdown = document.getElementById('iam-endpoint-dropdown');
const configured = uniqNonEmpty(configuredIamGateways);
dropdown.innerHTML = '';
if (configured.length === 0) {
dropdown.innerHTML = '<div class="px-4 py-3 text-gray-400 text-sm italic">No IAM gateways configured</div>';
return;
}
configured.forEach(endpoint => {
const item = document.createElement('div');
item.className = 'custom-dropdown-item';
item.textContent = endpoint;
item.addEventListener('click', () => selectIamEndpoint(endpoint));
dropdown.appendChild(item);
});
}
// Select an IAM endpoint from dropdown
function selectIamEndpoint(endpoint) {
document.getElementById('iam-endpoint-select').value = endpoint;
document.getElementById('iam-endpoint-dropdown').classList.remove('show');
onIamEndpointInput(endpoint);
}
// Select a region from dropdown
function selectRegion(value) {
const display = document.getElementById('region-display');
@@ -488,15 +588,47 @@ under the License.
// For now, just acknowledge the change. Can be extended with admin-specific logic.
}
// Handle IAM endpoint input. An empty value is valid and simply means the
// IAM tab never lights up for this session.
function onIamEndpointInput(endpoint) {
// No auto-fill: IAM endpoints are not part of the recent-gateways history.
applyEndpointVisibility();
}
// Region and addressing style only describe how to talk to an S3
// gateway, so an IAM-only sign-in has nothing to configure there.
//
// The Admin API endpoint hides whenever an IAM endpoint is in play at all,
// S3 or not: the IAM service manages users and bucket ownership is fixed,
// leaving the admin API no job.
function applyEndpointVisibility() {
const s3Endpoint = document.getElementById('endpoint-select').value.trim();
const iamEndpoint = document.getElementById('iam-endpoint-select').value.trim();
const showS3Options = !!s3Endpoint || !iamEndpoint;
const adminField = document.getElementById('admin-endpoint-field');
if (adminField) adminField.style.display = iamEndpoint ? 'none' : '';
['region-field', 'addressing-style-field'].forEach(id => {
const field = document.getElementById(id);
if (field) field.style.display = showS3Options ? '' : 'none';
});
}
// Keep behavior consistent if user types an endpoint manually
document.getElementById('endpoint-select').addEventListener('input', (e) => {
onEndpointInput(e.target.value);
applyEndpointVisibility();
});
document.getElementById('admin-endpoint-select').addEventListener('input', (e) => {
onAdminEndpointInput(e.target.value);
});
document.getElementById('iam-endpoint-select').addEventListener('input', (e) => {
onIamEndpointInput(e.target.value);
});
// Helper to set region (works with custom dropdown)
function setRegion(region) {
const dropdown = document.getElementById('region-dropdown');
@@ -559,18 +691,16 @@ under the License.
const s3Endpoint = document.getElementById('endpoint-select').value.trim();
const adminEndpoint = document.getElementById('admin-endpoint-select').value.trim();
const iamEndpoint = document.getElementById('iam-endpoint-select').value.trim();
const accessKey = document.getElementById('access-key').value.trim();
const secretKey = document.getElementById('secret-key').value;
const region = getSelectedRegion();
const addressingStyle = document.getElementById('addressing-style').value;
// Validate inputs
if (!s3Endpoint) {
showError('Please enter an S3 API endpoint.');
return;
}
if (!adminEndpoint) {
showError('Please enter an Admin API endpoint.');
// Validate inputs. Either endpoint alone is a complete sign-in: this UI
// serves an S3-only deployment, an IAM-only one, and one running both.
if (!s3Endpoint && !iamEndpoint) {
showError('Please enter an S3 API endpoint, an IAM API endpoint, or both.');
return;
}
if (!accessKey || !secretKey) {
@@ -579,7 +709,7 @@ under the License.
}
// Validate that virtual host style is not used with IP addresses
if (addressingStyle === 'virtual-host') {
if (s3Endpoint && addressingStyle === 'virtual-host') {
try {
const url = new URL(s3Endpoint);
const hostname = url.hostname;
@@ -600,10 +730,21 @@ under the License.
setLoading(submitBtn, true);
try {
// Set credentials with admin endpoint, then configure s3 endpoint separately
api.setCredentials(adminEndpoint, accessKey, secretKey, region);
// An empty field means "same place", not "no admin API": the Admin
// API shares the S3 gateway's port unless given one of its own, which
// is what the server assumes when --admin-port is unset.
//
// With an IAM endpoint in play the admin API is ignored outright, so
// the session gets no admin endpoint at all - which is what keeps
// every admin-only surface off screen. Its field is hidden but may
// still hold a server-prefilled value.
api.setCredentials(iamEndpoint ? '' : (adminEndpoint || s3Endpoint), accessKey, secretKey, region);
api.setS3Endpoint(s3Endpoint);
api.setAddressingStyle(addressingStyle);
// Optional third endpoint: only configured when the field is filled in
if (iamEndpoint) {
api.setIAMEndpoint(iamEndpoint);
}
const role = await api.detectRole();
if (role === 'none') {
@@ -617,18 +758,15 @@ under the License.
let userType = role === 'admin' ? 'admin' : 'user';
api.setUserContext(userType, []);
// Save gateway to recent list
const rememberKey = document.getElementById('remember-access-key').checked;
saveRecentGateway(s3Endpoint, region, accessKey, rememberKey);
// Save gateway to recent list. The history is keyed by S3 endpoint, so
// an IAM-only sign-in has nothing to record.
if (s3Endpoint) {
const rememberKey = document.getElementById('remember-access-key').checked;
saveRecentGateway(s3Endpoint, region, accessKey, rememberKey);
}
// Navigate based on role
if (role === 'admin') {
// Admin user - redirect to dashboard
window.location.href = 'dashboard.html';
} else {
// Regular user with S3 access - redirect to explorer
window.location.href = 'explorer.html';
}
window.location.href = defaultLandingPage();
} catch (error) {
api.logout();
console.error('Login error:', error);
+735 -47
View File
@@ -119,7 +119,11 @@ class VersityAPI {
this.s3Endpoint = null; // S3 API endpoint (always required)
this.region = 'us-east-1';
this.addressingStyle = 'path'; // 'path' or 'virtual-host'
this.iamEndpoint = null; // Standalone IAM service endpoint (optional)
this._isAdmin = false; // Role flag
this._hasIAM = false; // Credentials validate against the IAM service
this._hasS3 = false; // Credentials validate against the S3 data plane
this._canListBuckets = false; // hasS3 is true and s3:ListAllMyBuckets is allowed
}
/**
@@ -194,10 +198,12 @@ class VersityAPI {
}
/**
* Set credentials for API requests (initial login - assumes same endpoint)
* Set credentials for API requests (initial login - assumes same endpoint).
* An empty endpoint is valid: an IAM-only sign-in has no S3 gateway behind
* it, and gets its only endpoint from setIAMEndpoint().
*/
setCredentials(endpoint, accessKey, secretKey, region = 'us-east-1') {
endpoint = endpoint.replace(/\/$/, ''); // Remove trailing slash
endpoint = (endpoint || '').trim().replace(/\/$/, '') || null;
this.adminEndpoint = endpoint;
this.s3Endpoint = endpoint;
this.credentials = { accessKey, secretKey };
@@ -205,8 +211,8 @@ class VersityAPI {
this._isAdmin = false; // Will be set by detectRole()
// Store in sessionStorage for persistence across page loads
sessionStorage.setItem('vgw_admin_endpoint', this.adminEndpoint);
sessionStorage.setItem('vgw_s3_endpoint', this.s3Endpoint);
this._storeEndpoint('vgw_admin_endpoint', this.adminEndpoint);
this._storeEndpoint('vgw_s3_endpoint', this.s3Endpoint);
sessionStorage.setItem('vgw_access_key', accessKey);
sessionStorage.setItem('vgw_secret_key', secretKey);
sessionStorage.setItem('vgw_region', region);
@@ -217,8 +223,34 @@ class VersityAPI {
* Set the S3 endpoint separately (when different from admin)
*/
setS3Endpoint(s3Endpoint) {
this.s3Endpoint = s3Endpoint.replace(/\/$/, '');
sessionStorage.setItem('vgw_s3_endpoint', this.s3Endpoint);
this.s3Endpoint = (s3Endpoint || '').trim().replace(/\/$/, '') || null;
this._storeEndpoint('vgw_s3_endpoint', this.s3Endpoint);
}
/**
* Persist an endpoint, or clear the key when there isn't one, so that
* "is this endpoint configured?" is a plain presence check.
*/
_storeEndpoint(key, value) {
if (value) {
sessionStorage.setItem(key, value);
} else {
sessionStorage.removeItem(key);
}
}
/**
* Set the standalone IAM service endpoint (versitygw iam). Optional:
* without one the IAM navigation and pages stay hidden for the session.
*/
setIAMEndpoint(iamEndpoint) {
const normalized = (iamEndpoint || '').trim().replace(/\/$/, '');
this.iamEndpoint = normalized || null;
if (this.iamEndpoint) {
sessionStorage.setItem('vgw_iam_endpoint', this.iamEndpoint);
} else {
sessionStorage.removeItem('vgw_iam_endpoint');
}
}
/**
@@ -237,6 +269,32 @@ class VersityAPI {
sessionStorage.setItem('vgw_is_admin', isAdmin ? 'true' : 'false');
}
/**
* Set IAM service access flag (independent of the admin flag)
*/
setHasIAM(hasIAM) {
this._hasIAM = !!hasIAM;
sessionStorage.setItem('vgw_has_iam', this._hasIAM ? 'true' : 'false');
}
/**
* Set S3 data-plane access flag (used to route IAM-only sessions)
*/
setHasS3(hasS3) {
this._hasS3 = !!hasS3;
sessionStorage.setItem('vgw_has_s3', this._hasS3 ? 'true' : 'false');
}
/**
* Set whether the session may enumerate all buckets (s3:ListAllMyBuckets),
* which is separate from having valid S3 credentials: a policy can grant
* named buckets without the account-wide listing.
*/
setCanListBuckets(canListBuckets) {
this._canListBuckets = !!canListBuckets;
sessionStorage.setItem('vgw_can_list_buckets', this._canListBuckets ? 'true' : 'false');
}
/**
* Load credentials from sessionStorage
*/
@@ -248,17 +306,25 @@ class VersityAPI {
const region = sessionStorage.getItem('vgw_region') || 'us-east-1';
const addressingStyle = sessionStorage.getItem('vgw_addressing_style') || 'path';
const isAdmin = sessionStorage.getItem('vgw_is_admin') === 'true';
const iamEndpoint = sessionStorage.getItem('vgw_iam_endpoint');
const hasIAM = sessionStorage.getItem('vgw_has_iam') === 'true';
const hasS3 = sessionStorage.getItem('vgw_has_s3') === 'true';
const canListBuckets = sessionStorage.getItem('vgw_can_list_buckets') === 'true';
// Support legacy single endpoint storage
const legacyEndpoint = sessionStorage.getItem('vgw_endpoint');
if ((s3Endpoint || legacyEndpoint) && accessKey && secretKey) {
this.adminEndpoint = adminEndpoint || legacyEndpoint;
this.s3Endpoint = s3Endpoint || legacyEndpoint;
if ((s3Endpoint || legacyEndpoint || iamEndpoint) && accessKey && secretKey) {
this.adminEndpoint = adminEndpoint || legacyEndpoint || null;
this.s3Endpoint = s3Endpoint || legacyEndpoint || null;
this.credentials = { accessKey, secretKey };
this.region = region;
this.addressingStyle = addressingStyle;
this._isAdmin = isAdmin;
this.iamEndpoint = iamEndpoint || null;
this._hasIAM = hasIAM;
this._hasS3 = hasS3;
this._canListBuckets = canListBuckets;
return true;
}
return false;
@@ -272,7 +338,11 @@ class VersityAPI {
this.adminEndpoint = null;
this.s3Endpoint = null;
this.addressingStyle = 'path';
this.iamEndpoint = null;
this._isAdmin = false;
this._hasIAM = false;
this._hasS3 = false;
this._canListBuckets = false;
this._userType = 'user';
this._accessibleGateways = [];
sessionStorage.removeItem('vgw_admin_endpoint');
@@ -283,6 +353,11 @@ class VersityAPI {
sessionStorage.removeItem('vgw_region');
sessionStorage.removeItem('vgw_addressing_style');
sessionStorage.removeItem('vgw_is_admin');
sessionStorage.removeItem('vgw_iam_endpoint');
sessionStorage.removeItem('vgw_has_iam');
sessionStorage.removeItem('vgw_has_s3');
sessionStorage.removeItem('vgw_can_list_buckets');
sessionStorage.removeItem('vgw_iam_probe_error');
sessionStorage.removeItem('vgw_user_type');
sessionStorage.removeItem('vgw_accessible_gateways');
}
@@ -316,6 +391,38 @@ class VersityAPI {
return this._isAdmin;
}
/**
* Check whether the session's credentials reach the standalone IAM service
*/
hasIAM() {
return this._hasIAM;
}
/**
* Check whether the session's credentials reach the S3 data plane
*/
hasS3() {
return this._hasS3;
}
/**
* Check whether the session belongs on the management pages (Dashboard,
* Buckets): a classic admin session, or any S3 session in a standalone-IAM
* deployment, where those pages run on the S3 and IAM APIs alone and each
* denial is surfaced in place rather than hiding the page.
*/
hasManagement() {
return this._isAdmin || (this._hasS3 && this._hasIAM);
}
/**
* Check whether the session may enumerate all buckets
* (s3:ListAllMyBuckets). hasS3() can be true while this is false.
*/
canListBuckets() {
return this._canListBuckets;
}
/**
* Get current credentials info (without secret)
*/
@@ -327,7 +434,9 @@ class VersityAPI {
endpoint: this.s3Endpoint, // Legacy compatibility
accessKey: this.credentials.accessKey,
region: this.region,
isAdmin: this._isAdmin
isAdmin: this._isAdmin,
iamEndpoint: this.iamEndpoint,
hasIAM: this._hasIAM
};
}
@@ -503,8 +612,11 @@ class VersityAPI {
* @param {Object} queryParams - Query parameters
* @param {string} body - Request body
* @param {boolean} useAdminEndpoint - Use admin endpoint instead of S3
* @param {string} contentType - Content type (signed for methods with a body)
* @param {Object} extraSignedHeaders - Headers to include in the signature
* as well as the request, required for every x-amz-* header
*/
async signRequest(method, path, queryParams = {}, body = '', useAdminEndpoint = false, contentType = 'application/xml') {
async signRequest(method, path, queryParams = {}, body = '', useAdminEndpoint = false, contentType = 'application/xml', extraSignedHeaders = {}) {
if (!this.credentials) {
throw new Error('Not authenticated');
}
@@ -547,6 +659,10 @@ class VersityAPI {
headers['content-type'] = contentType;
}
Object.entries(extraSignedHeaders).forEach(([key, value]) => {
headers[key.toLowerCase()] = String(value).trim();
});
const signedHeadersList = Object.keys(headers).sort();
const signedHeaders = signedHeadersList.join(';');
const canonicalHeaders = signedHeadersList.map(h => `${h}:${headers[h]}\n`).join('');
@@ -591,6 +707,10 @@ class VersityAPI {
responseHeaders['Content-Type'] = contentType;
}
Object.entries(extraSignedHeaders).forEach(([key, value]) => {
responseHeaders[key] = String(value).trim();
});
return {
url: url.toString(),
headers: responseHeaders
@@ -704,9 +824,18 @@ class VersityAPI {
// Always sign and send directly to the configured endpoint.
// CORS must be configured on the S3 endpoint.
const signed = await this.signRequest(method, path, queryParams, body, useAdminEndpoint, contentType);
//
// x-amz-* headers must be covered by the signature or the gateway
// rejects the request; anything else rides along unsigned.
const amzHeaders = {};
const plainHeaders = {};
Object.entries(additionalHeaders).forEach(([key, value]) => {
(key.toLowerCase().startsWith('x-amz-') ? amzHeaders : plainHeaders)[key] = value;
});
const signed = await this.signRequest(method, path, queryParams, body, useAdminEndpoint, contentType, amzHeaders);
const fetchUrl = signed.url;
const headers = { ...signed.headers, ...additionalHeaders };
const headers = { ...signed.headers, ...plainHeaders };
let response;
try {
@@ -728,18 +857,7 @@ class VersityAPI {
const responseText = await response.text();
if (!response.ok) {
// Try to parse error from XML
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(responseText, 'text/xml');
const code = xmlDoc.querySelector('Code')?.textContent;
const message = xmlDoc.querySelector('Message')?.textContent;
if (code) errorMessage = `${code}: ${message || 'Unknown error'}`;
} catch (e) {
// Ignore parsing errors
}
throw new Error(errorMessage);
throw new Error(parseXmlErrorMessage(responseText, `HTTP ${response.status}: ${response.statusText}`));
}
return responseText;
@@ -779,32 +897,100 @@ class VersityAPI {
}
/**
* Detect user role by trying admin API first, then S3 API
* Returns: 'admin' | 's3' | 'none'
* Detect what the credentials can reach across all three configured
* endpoints: S3 data plane, Admin API, and the standalone IAM service.
* Every applicable probe runs, so one success does not mask another.
*
* Returns: 'admin' | 's3' | 'iam' | 'none'
*/
async detectRole() {
// Validate S3 credentials first.
// This avoids blocking non-admin users on an expected admin-API failure.
try {
await this.listBucketsS3();
this.setAdminRole(false);
} catch (s3Error) {
// If the request failed at the network level (CORS, TLS, or unreachable),
// surface that error so the UI can show a useful diagnostic message.
if (s3Error && typeof s3Error.message === 'string' && s3Error.message.startsWith('Network error:')) {
throw s3Error;
let hasS3 = false;
let canListBuckets = false;
let isAdmin = false;
let hasIAM = false;
let networkError = null;
const isNetworkError = (e) =>
e && typeof e.message === 'string' && e.message.startsWith('Network error:');
// 1. S3 data plane. An unconfigured endpoint is an IAM-only sign-in, not
// a failure, so skip the probe rather than rejecting the credentials.
//
// ListBuckets needs s3:ListAllMyBuckets, which a user scoped to named
// buckets may lack while still being able to use the Explorer. Only
// AccessDenied draws that line: it comes back after the signature and
// account checks pass, unlike the other HTTP 403s
// (InvalidAccessKeyId/SignatureDoesNotMatch/ExpiredToken), which mean the
// credentials themselves were rejected.
if (this.s3Endpoint) {
try {
await this.listBucketsS3();
hasS3 = true;
canListBuckets = true;
} catch (s3Error) {
if (isNetworkError(s3Error)) {
networkError = s3Error;
} else if (s3Error.code === 'AccessDenied') {
hasS3 = true;
canListBuckets = false;
}
}
}
// 2. Admin API — only meaningful once S3 access is established. All admin
// routes share the same role gate, so probe with list-buckets: list-users
// also needs an IAM backend that can enumerate accounts, which the
// standalone IAM backend cannot.
if (hasS3 && this.adminEndpoint) {
try {
await this.listBuckets();
isAdmin = true;
// Admin/root always bypasses the ListAllMyBuckets policy check.
canListBuckets = true;
} catch (adminError) {
// Expected for non-admin accounts
}
}
// 3. Standalone IAM service. GetCallerIdentity needs no policy grant, so
// it is the only action any valid credential is sure to be allowed.
let iamProbeError = null;
if (this.iamEndpoint) {
try {
await this.iamGetCallerIdentity();
hasIAM = true;
} catch (iamError) {
iamProbeError = iamError;
if (isNetworkError(iamError) && !networkError) networkError = iamError;
}
}
if (!hasS3 && !hasIAM) {
// Surface a network-level diagnostic instead of a plain auth failure.
if (networkError) throw networkError;
return 'none';
}
// S3 works, now test admin API access.
try {
const users = await this.listUsers();
this.setAdminRole(true);
return 'admin';
} catch (adminError) {
return 's3';
// At least one source accepted the credentials, so a network failure on
// another is logged rather than failing an otherwise valid session.
if (networkError) console.warn(networkError.message);
this.setAdminRole(isAdmin);
this.setHasIAM(hasIAM);
this.setHasS3(hasS3);
this.setCanListBuckets(canListBuckets);
// A configured IAM endpoint that did not answer is worth saying out loud:
// the usual cause is a missing --cors-allow-origin, whose only other
// symptom is an IAM tab that never appears.
sessionStorage.removeItem('vgw_iam_probe_error');
if (iamProbeError && !hasIAM) {
sessionStorage.setItem('vgw_iam_probe_error', iamProbeError.message);
}
if (isAdmin) return 'admin';
if (hasS3) return 's3';
return 'iam';
}
// ============================================
@@ -944,16 +1130,22 @@ class VersityAPI {
if (!httpResponse.ok) {
// Try to parse error from XML
let errorMessage = `HTTP ${httpResponse.status}: ${httpResponse.statusText}`;
let code = null;
try {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(response, 'text/xml');
const code = xmlDoc.querySelector('Code')?.textContent;
code = xmlDoc.querySelector('Code')?.textContent || null;
const message = xmlDoc.querySelector('Message')?.textContent;
if (code) errorMessage = `${code}: ${message || 'Unknown error'}`;
} catch (e) {
// Ignore parsing errors
}
throw new Error(errorMessage);
const err = new Error(errorMessage);
// Both are HTTP 403, so the code is what tells "credentials rejected"
// apart from "credentials valid, action forbidden" (AccessDenied).
err.status = httpResponse.status;
err.code = code;
throw err;
}
const parser = new DOMParser();
@@ -1018,16 +1210,27 @@ class VersityAPI {
/**
* Create a new bucket with bucket name(s3api)
* @param {string} bucketName - The name of the bucket to create
* @param {boolean} enableObjectLock - Whether to enable object lock
* (which enables versioning as well)
*/
async createBucket(bucketName) {
async createBucket(bucketName, enableObjectLock = false) {
if (!bucketName) {
throw new Error('Bucket name is required');
}
const headers = {};
if (enableObjectLock) {
headers['x-amz-bucket-object-lock-enabled'] = 'true';
}
await this.request(
'PUT',
`/${bucketName}`,
{},
'',
false,
'application/xml',
headers
);
}
@@ -2061,6 +2264,454 @@ ${tagsXml}
}
return result;
}
// ============================================
// Standalone IAM Service (AWS Query protocol)
// ============================================
/**
* Sign a request against the standalone IAM service: signRequest()'s
* POST/body-hashing branch with a parameterized SigV4 service. Two
* deliberate differences from the S3/Admin path:
* - the signing region is fixed to us-east-1 (iammiddleware.SigningRegion);
* the login page's region yields InvalidRegion.
* - the body is form-encoded (Action/Version/params), not XML.
*
* @param {string} action - IAM or STS action name
* @param {Object} params - Additional query-protocol parameters
* @param {string} sigService - 'iam' or 'sts'
* @returns {Object} - { url, headers, body }
*/
async signIamRequest(action, params = {}, sigService = 'iam') {
if (!this.credentials) {
throw new Error('Not authenticated');
}
if (!this.iamEndpoint) {
throw new Error('IAM API endpoint is not configured');
}
const url = new URL(this.iamEndpoint + '/');
const host = url.host;
const amzDate = this.getAmzDate();
const dateStamp = this.getDateStamp();
const region = IAM_SIGNING_REGION;
const form = new URLSearchParams();
form.set('Action', action);
form.set('Version', sigService === 'sts' ? STS_API_VERSION : IAM_API_VERSION);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) form.set(key, String(value));
});
const body = form.toString();
const contentType = 'application/x-www-form-urlencoded';
const payloadHash = await this.sha256(body);
const headers = {
'content-type': contentType,
'host': host,
'x-amz-content-sha256': payloadHash,
'x-amz-date': amzDate,
};
const signedHeadersList = Object.keys(headers).sort();
const signedHeaders = signedHeadersList.join(';');
const canonicalHeaders = signedHeadersList.map(h => `${h}:${headers[h]}\n`).join('');
const canonicalRequest = [
'POST',
url.pathname,
'',
canonicalHeaders,
signedHeaders,
payloadHash
].join('\n');
const algorithm = 'AWS4-HMAC-SHA256';
const credentialScope = `${dateStamp}/${region}/${sigService}/aws4_request`;
const canonicalRequestHash = await this.sha256(canonicalRequest);
const stringToSign = [algorithm, amzDate, credentialScope, canonicalRequestHash].join('\n');
const signingKey = await this.getSigningKey(this.credentials.secretKey, dateStamp, region, sigService);
const signatureBuffer = await this.hmacSha256(signingKey, stringToSign);
const signature = this.bufferToHex(signatureBuffer);
return {
url: url.toString(),
headers: {
'Authorization': `${algorithm} Credential=${this.credentials.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
'X-Amz-Date': amzDate,
'X-Amz-Content-Sha256': payloadHash,
'Content-Type': contentType,
},
body
};
}
/**
* Make a signed IAM/STS request and parse the XML response
*/
async iamRequest(action, params = {}, sigService = 'iam') {
const signed = await this.signIamRequest(action, params, sigService);
let response;
try {
response = await fetch(signed.url, {
method: 'POST',
headers: signed.headers,
body: signed.body,
});
} catch (e) {
if (e instanceof TypeError) {
throw new Error(`Network error: cannot reach the IAM service. Common causes: CORS policy (the IAM endpoint must allow origin ${window.location.origin}), TLS/certificate error (untrusted or self-signed certificate rejected by the browser), or the service is unreachable.`);
}
throw e;
}
const responseText = await response.text();
if (!response.ok) {
throw new Error(parseXmlErrorMessage(responseText, `HTTP ${response.status}: ${response.statusText}`));
}
return this.parseIamResponse(responseText);
}
/**
* Parse an <ActionResponse><ActionResult> envelope into plain JS
*/
parseIamResponse(xmlString) {
if (!xmlString || !xmlString.trim()) return {};
const xmlDoc = new DOMParser().parseFromString(xmlString, 'text/xml');
if (xmlDoc.querySelector('parsererror')) {
throw new Error('Invalid XML response from the IAM service');
}
const root = xmlDoc.documentElement;
const result = Array.from(root.children).find(c => /Result$/.test(c.tagName));
const parsed = this.iamXmlToJs(result || root);
return (parsed && typeof parsed === 'object') ? parsed : {};
}
/**
* Generic XML -> JS conversion. Any element whose children are all <member>
* collapses into an array, which is the one list convention this API uses.
*/
iamXmlToJs(el) {
const children = Array.from(el.children);
if (children.length === 0) return el.textContent;
if (children.every(c => c.tagName === 'member')) {
return children.map(c => this.iamXmlToJs(c));
}
const out = {};
children.forEach(child => {
const value = this.iamXmlToJs(child);
if (child.tagName in out) {
out[child.tagName] = [].concat(out[child.tagName], value);
} else {
out[child.tagName] = value;
}
});
return out;
}
/**
* Decode a percent-encoded policy document (iamutil.EncodePolicyDocument)
*/
decodePolicyDocument(document) {
if (typeof document !== 'string' || !document) return document;
try {
return decodeURIComponent(document);
} catch (e) {
return document;
}
}
/**
* Write Tags.member.N.Key / Tags.member.N.Value params
*/
flattenTags(params, tags) {
(tags || []).forEach((tag, i) => {
if (!tag || !tag.Key) return;
params[`Tags.member.${i + 1}.Key`] = tag.Key;
params[`Tags.member.${i + 1}.Value`] = tag.Value || '';
});
return params;
}
/**
* Write <name>.member.N params
*/
flattenMemberList(params, name, values) {
(values || []).forEach((value, i) => {
if (value === undefined || value === null || value === '') return;
params[`${name}.member.${i + 1}`] = value;
});
return params;
}
// ---- IAM users ----
async iamCreateUser(userName, path, tags) {
const params = { UserName: userName };
if (path) params.Path = path;
this.flattenTags(params, tags);
const result = await this.iamRequest('CreateUser', params);
return result.User || {};
}
async iamGetUser(userName) {
const params = {};
if (userName) params.UserName = userName;
const result = await this.iamRequest('GetUser', params);
return result.User || {};
}
async iamListUsers(options = {}) {
const params = {};
if (options.pathPrefix) params.PathPrefix = options.pathPrefix;
if (options.marker) params.Marker = options.marker;
if (options.maxItems) params.MaxItems = options.maxItems;
const result = await this.iamRequest('ListUsers', params);
return {
users: iamAsArray(result.Users),
isTruncated: result.IsTruncated === 'true',
marker: result.Marker || null
};
}
async iamUpdateUser(userName, newUserName, newPath) {
const params = { UserName: userName };
if (newUserName) params.NewUserName = newUserName;
if (newPath) params.NewPath = newPath;
const result = await this.iamRequest('UpdateUser', params);
return result.User || {};
}
async iamDeleteUser(userName) {
await this.iamRequest('DeleteUser', { UserName: userName });
}
// ---- Access keys ----
/**
* Create an access key. The server generates both halves of the key pair;
* the secret is returned here and nowhere else, ever again.
*/
async iamCreateAccessKey(userName) {
const result = await this.iamRequest('CreateAccessKey', { UserName: userName });
return result.AccessKey || {};
}
async iamUpdateAccessKey(userName, accessKeyId, status) {
await this.iamRequest('UpdateAccessKey', { UserName: userName, AccessKeyId: accessKeyId, Status: status });
}
async iamDeleteAccessKey(userName, accessKeyId) {
await this.iamRequest('DeleteAccessKey', { UserName: userName, AccessKeyId: accessKeyId });
}
async iamListAccessKeys(userName, options = {}) {
const params = { UserName: userName };
if (options.marker) params.Marker = options.marker;
if (options.maxItems) params.MaxItems = options.maxItems;
const result = await this.iamRequest('ListAccessKeys', params);
return {
keys: iamAsArray(result.AccessKeyMetadata),
isTruncated: result.IsTruncated === 'true',
marker: result.Marker || null
};
}
async iamGetAccessKeyLastUsed(accessKeyId) {
const result = await this.iamRequest('GetAccessKeyLastUsed', { AccessKeyId: accessKeyId });
return {
userName: result.UserName || '',
lastUsed: result.AccessKeyLastUsed || {}
};
}
// ---- User inline policies ----
async iamPutUserPolicy(userName, policyName, policyDocument) {
await this.iamRequest('PutUserPolicy', { UserName: userName, PolicyName: policyName, PolicyDocument: policyDocument });
}
async iamGetUserPolicy(userName, policyName) {
const result = await this.iamRequest('GetUserPolicy', { UserName: userName, PolicyName: policyName });
return {
userName: result.UserName || userName,
policyName: result.PolicyName || policyName,
policyDocument: this.decodePolicyDocument(result.PolicyDocument || '')
};
}
async iamDeleteUserPolicy(userName, policyName) {
await this.iamRequest('DeleteUserPolicy', { UserName: userName, PolicyName: policyName });
}
async iamListUserPolicies(userName, options = {}) {
const params = { UserName: userName };
if (options.marker) params.Marker = options.marker;
if (options.maxItems) params.MaxItems = options.maxItems;
const result = await this.iamRequest('ListUserPolicies', params);
return {
policyNames: iamAsArray(result.PolicyNames),
isTruncated: result.IsTruncated === 'true',
marker: result.Marker || null
};
}
// ---- Roles ----
async iamCreateRole(roleName, assumeRolePolicyDocument, options = {}) {
const params = { RoleName: roleName, AssumeRolePolicyDocument: assumeRolePolicyDocument };
if (options.path) params.Path = options.path;
if (options.description) params.Description = options.description;
if (options.maxSessionDuration) params.MaxSessionDuration = options.maxSessionDuration;
this.flattenTags(params, options.tags);
const result = await this.iamRequest('CreateRole', params);
return this.decodeRoleTrustPolicy(result.Role || {});
}
async iamGetRole(roleName) {
const result = await this.iamRequest('GetRole', { RoleName: roleName });
return this.decodeRoleTrustPolicy(result.Role || {});
}
async iamListRoles(options = {}) {
const params = {};
if (options.pathPrefix) params.PathPrefix = options.pathPrefix;
if (options.marker) params.Marker = options.marker;
if (options.maxItems) params.MaxItems = options.maxItems;
const result = await this.iamRequest('ListRoles', params);
return {
roles: iamAsArray(result.Roles).map(role => this.decodeRoleTrustPolicy(role)),
isTruncated: result.IsTruncated === 'true',
marker: result.Marker || null
};
}
async iamDeleteRole(roleName) {
await this.iamRequest('DeleteRole', { RoleName: roleName });
}
async iamUpdateAssumeRolePolicy(roleName, policyDocument) {
await this.iamRequest('UpdateAssumeRolePolicy', { RoleName: roleName, PolicyDocument: policyDocument });
}
/**
* Roles echo their trust policy percent-encoded on create/get/list
*/
decodeRoleTrustPolicy(role) {
if (role && role.AssumeRolePolicyDocument) {
role.AssumeRolePolicyDocument = this.decodePolicyDocument(role.AssumeRolePolicyDocument);
}
return role || {};
}
// ---- Role inline policies ----
async iamPutRolePolicy(roleName, policyName, policyDocument) {
await this.iamRequest('PutRolePolicy', { RoleName: roleName, PolicyName: policyName, PolicyDocument: policyDocument });
}
async iamGetRolePolicy(roleName, policyName) {
const result = await this.iamRequest('GetRolePolicy', { RoleName: roleName, PolicyName: policyName });
return {
roleName: result.RoleName || roleName,
policyName: result.PolicyName || policyName,
policyDocument: this.decodePolicyDocument(result.PolicyDocument || '')
};
}
async iamDeleteRolePolicy(roleName, policyName) {
await this.iamRequest('DeleteRolePolicy', { RoleName: roleName, PolicyName: policyName });
}
async iamListRolePolicies(roleName, options = {}) {
const params = { RoleName: roleName };
if (options.marker) params.Marker = options.marker;
if (options.maxItems) params.MaxItems = options.maxItems;
const result = await this.iamRequest('ListRolePolicies', params);
return {
policyNames: iamAsArray(result.PolicyNames),
isTruncated: result.IsTruncated === 'true',
marker: result.Marker || null
};
}
// ---- OIDC identity providers ----
async iamCreateOIDCProvider(url, clientIDList, thumbprintList, tags) {
const params = { Url: url };
this.flattenMemberList(params, 'ClientIDList', clientIDList);
this.flattenMemberList(params, 'ThumbprintList', thumbprintList);
this.flattenTags(params, tags);
const result = await this.iamRequest('CreateOpenIDConnectProvider', params);
return {
arn: result.OpenIDConnectProviderArn || '',
tags: iamAsArray(result.Tags)
};
}
async iamGetOIDCProvider(arn) {
const result = await this.iamRequest('GetOpenIDConnectProvider', { OpenIDConnectProviderArn: arn });
return {
url: result.Url || '',
clientIDList: iamAsArray(result.ClientIDList),
thumbprintList: iamAsArray(result.ThumbprintList),
createDate: result.CreateDate || '',
tags: iamAsArray(result.Tags)
};
}
/**
* List OIDC providers. This action returns ARNs only and has no pagination.
*/
async iamListOIDCProviders() {
const result = await this.iamRequest('ListOpenIDConnectProviders', {});
return iamAsArray(result.OpenIDConnectProviderList)
.map(entry => (typeof entry === 'string' ? entry : entry.Arn))
.filter(Boolean);
}
async iamDeleteOIDCProvider(arn) {
await this.iamRequest('DeleteOpenIDConnectProvider', { OpenIDConnectProviderArn: arn });
}
async iamAddClientIDToOIDCProvider(arn, clientID) {
await this.iamRequest('AddClientIDToOpenIDConnectProvider', { OpenIDConnectProviderArn: arn, ClientID: clientID });
}
async iamRemoveClientIDFromOIDCProvider(arn, clientID) {
await this.iamRequest('RemoveClientIDFromOpenIDConnectProvider', { OpenIDConnectProviderArn: arn, ClientID: clientID });
}
/**
* Replace the provider's entire thumbprint list
*/
async iamUpdateOIDCProviderThumbprint(arn, thumbprintList) {
const params = { OpenIDConnectProviderArn: arn };
this.flattenMemberList(params, 'ThumbprintList', thumbprintList);
await this.iamRequest('UpdateOpenIDConnectProviderThumbprint', params);
}
// ---- Self identity (STS) ----
/**
* GetCallerIdentity requires no policy grant for any authenticated identity,
* which makes it both the login-time IAM probe and the "My Identity" source.
*/
async iamGetCallerIdentity() {
const result = await this.iamRequest('GetCallerIdentity', {}, 'sts');
return {
arn: result.Arn || '',
userId: result.UserId || '',
account: result.Account || ''
};
}
}
/**
@@ -2079,5 +2730,42 @@ ${tagsXml}
);
}
/**
* IAM query-protocol constants. The signing region is fixed server-side by
* iammiddleware.SigningRegion and is deliberately independent of the region
* chosen on the login page.
*/
const IAM_API_VERSION = '2010-05-08';
const STS_API_VERSION = '2011-06-15';
const IAM_SIGNING_REGION = 'us-east-1';
/**
* Pull <Code>/<Message> out of an S3, Admin or IAM error body: all three
* error shapes carry the same two elements.
*
* @param {string} responseText - Raw response body
* @param {string} fallback - Message to use when nothing parses
* @returns {string} A human-readable error message
*/
function parseXmlErrorMessage(responseText, fallback) {
try {
const xmlDoc = new DOMParser().parseFromString(responseText, 'text/xml');
const code = xmlDoc.querySelector('Code')?.textContent;
const message = xmlDoc.querySelector('Message')?.textContent;
if (code) return `${code}: ${message || 'Unknown error'}`;
} catch (e) {
// Ignore parsing errors and fall through
}
return fallback;
}
/**
* Normalize a parsed member-list field to an array
*/
function iamAsArray(value) {
if (value === undefined || value === null || value === '') return [];
return Array.isArray(value) ? value : [value];
}
// Create global API instance
const api = new VersityAPI();
+143 -20
View File
@@ -34,9 +34,21 @@ function requireAuth() {
}
/**
* Require admin role, redirect non-admins to explorer
* Call this on admin-only pages (dashboard, users, buckets, settings)
* Also loads user context (user type and accessible gateways)
* The page a session belongs on when it has no business being where it is.
* Three deployments share this UI - S3 only, IAM only, and both - so there is
* no single fixed landing page.
*/
function defaultLandingPage() {
if (api.hasManagement()) return 'dashboard.html';
if (api.hasS3()) return 'explorer.html';
if (api.hasIAM()) return 'iam.html';
return 'index.html';
}
/**
* Require admin role, redirect non-admins to where they do belong.
* Call this on Admin-API-only pages, whose every action is gated by the admin
* role server-side. Also loads user context.
*/
function requireAdmin() {
if (!api.loadCredentials()) {
@@ -45,7 +57,82 @@ function requireAdmin() {
}
api.loadUserContext();
if (!api.isAdmin()) {
window.location.href = 'explorer.html';
window.location.href = defaultLandingPage();
return false;
}
return true;
}
/**
* Require management-page access (dashboard, buckets): a classic admin
* session, or any S3 session in a standalone-IAM deployment, where the pages
* run on the S3 and IAM APIs alone and surface each denial per action rather
* than redirecting.
*/
function requireManagement() {
if (!api.loadCredentials()) {
window.location.href = 'index.html';
return false;
}
api.loadUserContext();
if (!api.hasManagement()) {
window.location.href = defaultLandingPage();
return false;
}
return true;
}
/**
* Require the gateway's own account store to be this deployment's user
* directory. Call this on users.html, which manages that store through the
* Admin API; a configured standalone IAM service takes that job over instead.
*
* The IAM check runs first because a standalone-IAM session is never an admin,
* so testing admin first would bounce it to the landing page rather than to
* this page's IAM counterpart.
*/
function requireGatewayUsers() {
if (!api.loadCredentials()) {
window.location.href = 'index.html';
return false;
}
api.loadUserContext();
if (api.hasIAM()) {
window.location.href = 'iam-users.html';
return false;
}
return requireAdmin();
}
/**
* Require IAM service access, redirect sessions without it away.
* Call this on the IAM pages (iam, iam-users, iam-roles, iam-oidc).
*/
function requireIAM() {
if (!api.loadCredentials()) {
window.location.href = 'index.html';
return false;
}
api.loadUserContext();
if (!api.hasIAM()) {
window.location.href = defaultLandingPage();
return false;
}
return true;
}
/**
* Require S3 data-plane access, redirect sessions without it away.
* Call this on the S3-backed pages (explorer).
*/
function requireS3() {
if (!api.loadCredentials()) {
window.location.href = 'index.html';
return false;
}
api.loadUserContext();
if (!api.hasS3()) {
window.location.href = defaultLandingPage();
return false;
}
return true;
@@ -53,15 +140,10 @@ function requireAdmin() {
/**
* Redirect to appropriate page if already authenticated
* Admin users go to dashboard, regular users go to explorer
*/
function redirectIfAuthenticated() {
if (api.loadCredentials()) {
if (api.isAdmin()) {
window.location.href = 'dashboard.html';
} else {
window.location.href = 'explorer.html';
}
window.location.href = defaultLandingPage();
return true;
}
return false;
@@ -277,9 +359,11 @@ function debounce(func, wait) {
function initSidebar() {
const currentPage = window.location.pathname.split('/').pop() || 'index.html';
// The IAM nav item covers the whole iam-* page family
const onIamPage = currentPage.startsWith('iam');
document.querySelectorAll('.nav-item').forEach(item => {
const href = item.getAttribute('href');
if (href === currentPage) {
if (href === currentPage || (onIamPage && href === 'iam.html')) {
item.classList.add('active');
item.classList.remove('text-white/70');
item.classList.add('text-white');
@@ -293,6 +377,19 @@ function initSidebar() {
// Update User Info in Sidebar
// ============================================
/**
* What this session actually reaches, for the sidebar badge. "User" still
* means an S3-only session, so a deployment without IAM is unchanged.
*/
function sessionRoleLabel() {
if (api.isAdmin()) return 'Admin';
if (api.hasS3() && api.hasIAM()) return 'User + IAM';
// Not "IAM user": the session may be root, which only GetCallerIdentity
// can tell, and the IAM landing page does show that.
if (api.hasIAM()) return 'IAM';
return 'User';
}
function updateUserInfo() {
const info = api.getCredentialsInfo();
if (!info) return;
@@ -301,7 +398,7 @@ function updateUserInfo() {
? info.accessKey.substring(0, 12) + '...'
: info.accessKey;
const roleLabel = info.isAdmin ? 'Admin' : 'User';
const roleLabel = sessionRoleLabel();
const userInfoEl = document.getElementById('user-info');
if (userInfoEl) {
@@ -315,18 +412,44 @@ function updateUserInfo() {
}
/**
* Initialize sidebar with role-based navigation
* Hides admin-only nav items for non-admin users
* Initialize sidebar with capability-based navigation. What a session can
* reach is five separate questions rather than one role, so five gates:
* data-admin-only the Admin API answered for these credentials
* data-management-only the management pages (Dashboard, Buckets) apply:
* a classic admin, or any S3 session in a
* standalone-IAM deployment
* data-s3-only the S3 data plane answered for these credentials
* data-iam-only the standalone IAM service answered for them
* data-admin-users-only the inverse of data-iam-only: the gateway's own
* account store is still the user directory, rather
* than a configured standalone IAM service
*/
function initSidebarWithRole() {
initSidebar();
// Hide admin-only nav items for non-admin users
if (!api.isAdmin()) {
document.querySelectorAll('[data-admin-only]').forEach(item => {
item.style.display = 'none';
});
}
const hide = (selector) => document.querySelectorAll(selector).forEach(item => {
item.style.display = 'none';
});
if (!api.isAdmin()) hide('[data-admin-only]');
if (!api.hasManagement()) hide('[data-management-only]');
if (!api.hasS3()) hide('[data-s3-only]');
if (!api.hasIAM()) hide('[data-iam-only]');
if (api.hasIAM()) hide('[data-admin-users-only]');
reportIAMProbeFailure();
}
/**
* Report once, on whatever page the session landed on, that a configured IAM
* endpoint did not answer. The only other symptom is IAM navigation that
* silently never appears, usually for want of --cors-allow-origin.
*/
function reportIAMProbeFailure() {
const message = sessionStorage.getItem('vgw_iam_probe_error');
if (!message) return;
sessionStorage.removeItem('vgw_iam_probe_error');
showToast('IAM service unavailable, IAM management is hidden for this session. ' + message, 'error');
}
// ============================================
+641
View File
@@ -0,0 +1,641 @@
// 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.
/**
* VersityGW IAM - shared UI helpers for the IAM pages
*
* Loaded by iam.html, iam-users.html, iam-roles.html and iam-oidc.html after
* js/app.js. Holds the parts the three IAM pages would otherwise duplicate:
* the policy editor (identity and trust variants), repeatable form rows, the
* per-section access-denied state, and the shared quota numbers.
*/
// ============================================
// Server-side quotas (surfaced as helper text and disabled states)
// ============================================
const IAM_LIMITS = {
accessKeysPerUser: 2,
userPolicyBytes: 2048,
rolePolicyBytes: 10240,
trustPolicyBytes: 2048,
policyDocumentBytes: 131072,
roleDescriptionChars: 1000,
namePattern: /^[A-Za-z0-9+=,.@_-]+$/,
nameChars: 64,
pathChars: 512,
tagsPerResource: 50,
tagKeyChars: 128,
tagValueChars: 256,
minSessionDuration: 3600,
maxSessionDuration: 43200,
oidcClientIds: 100,
oidcClientIdChars: 255,
oidcThumbprints: 5,
oidcThumbprintChars: 40,
oidcUrlChars: 255,
listPageSize: 100
};
const IAM_ACCOUNT_ID = '000000000000';
// ============================================
// Formatting & small utilities
// ============================================
function iamFormatDate(value) {
if (!value) return '-';
const date = new Date(value);
if (isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function iamByteLength(text) {
return new TextEncoder().encode(text || '').length;
}
/**
* Copy machine-issued strings (ARNs, key IDs, thumbprints) to the clipboard
*/
async function iamCopy(text, label = 'Value') {
try {
await navigator.clipboard.writeText(text);
showToast(label + ' copied to clipboard', 'success');
} catch (e) {
showToast('Unable to copy to clipboard', 'error');
}
}
/**
* ARNs are long. Render them monospace, truncated, with a copy button.
*/
function iamArnCell(arn) {
if (!arn) return '<span class="text-charcoal-300">-</span>';
const safe = escapeHtml(arn);
return `<div class="flex items-center gap-2 min-w-0">
<span class="font-mono text-xs text-charcoal truncate max-w-[22rem]" title="${safe}">${safe}</span>
<button onclick="iamCopy('${safe}', 'ARN')" class="p-1 text-charcoal-300 hover:text-accent hover:bg-accent-50 rounded transition-colors flex-shrink-0" title="Copy ARN">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>`;
}
function iamStatusBadge(status) {
const active = status === 'Active';
const cls = active ? 'bg-green-50 text-green-700' : 'bg-yellow-50 text-yellow-700';
return `<span class="px-2 py-0.5 ${cls} text-xs font-medium rounded">${escapeHtml(status || '-')}</span>`;
}
/**
* Per-section denial state. Partial access is the common case for non-root
* callers, so a denied List* call reports itself in place instead of failing
* the whole page.
*/
function iamShowAccessDenied(tableBodyId, columns, message) {
const tbody = document.getElementById(tableBodyId);
if (!tbody) return;
tbody.innerHTML = `<tr><td colspan="${columns}" class="py-12 px-6 text-center">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
<p class="text-gray-500">${escapeHtml(message)}</p>
</td></tr>`;
}
/**
* Turn an IAM error into product copy. Deletion preconditions are the one case
* where the server's own message is less useful than a prescriptive one.
*/
function iamErrorText(error, context) {
const raw = (error && error.message) || 'Unknown error';
if (raw.startsWith('DeleteConflictPolicies') || raw.startsWith('DeleteConflict')) {
return raw.includes('Role') || context === 'role'
? 'This role still has inline policies. Remove them before deleting it.'
: 'This user still has access keys or inline policies. Remove them before deleting it.';
}
if (raw.startsWith('AccessKeysLimitExceeded')) {
return `This user already has ${IAM_LIMITS.accessKeysPerUser} access keys. Delete one before creating another.`;
}
if (raw.startsWith('InlinePolicyQuotaExceeded')) {
return 'Saving this policy would exceed the aggregate inline-policy size for this identity.';
}
return context ? `Error ${context}: ${raw}` : raw;
}
function iamIsAccessDenied(error) {
const raw = (error && error.message) || '';
return raw.startsWith('AccessDenied') || raw.startsWith('AuthorizationError');
}
/**
* Terse form of an error, for places with no room for a paragraph
* (stat-card notes, table cells)
*/
function iamShortError(error) {
const raw = (error && error.message) || 'Unknown error';
if (raw.startsWith('Network error:')) return 'IAM service unreachable';
return raw.length > 90 ? raw.slice(0, 90) + '\u2026' : raw;
}
// ============================================
// Name / path validation (client side, matching server rules)
// ============================================
function iamValidateName(name, label = 'Name') {
if (!name) return `${label} is required.`;
if (name.length > IAM_LIMITS.nameChars) return `${label} must be ${IAM_LIMITS.nameChars} characters or fewer.`;
if (!IAM_LIMITS.namePattern.test(name)) return `${label} may contain letters, numbers and + = , . @ _ - only.`;
return null;
}
function iamValidatePath(path) {
if (!path) return null;
if (path.length > IAM_LIMITS.pathChars) return `Path must be ${IAM_LIMITS.pathChars} characters or fewer.`;
if (!path.startsWith('/') || !path.endsWith('/')) return 'Path must start and end with /.';
return null;
}
// ============================================
// Repeatable form rows (tags, client IDs, thumbprints)
// ============================================
function iamRemoveRow(button) {
const row = button.closest('[data-iam-row]');
if (row) row.remove();
}
function iamAddTagRow(containerId, key = '', value = '') {
const container = document.getElementById(containerId);
if (!container) return;
if (container.querySelectorAll('[data-iam-row]').length >= IAM_LIMITS.tagsPerResource) {
showToast(`A resource can carry ${IAM_LIMITS.tagsPerResource} tags at most`, 'warning');
return;
}
const row = document.createElement('div');
row.className = 'flex items-center gap-2';
row.setAttribute('data-iam-row', 'tag');
row.innerHTML = `<input type="text" data-tag-key value="${escapeHtml(key)}" maxlength="${IAM_LIMITS.tagKeyChars}" placeholder="Key" class="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm text-charcoal placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<input type="text" data-tag-value value="${escapeHtml(value)}" maxlength="${IAM_LIMITS.tagValueChars}" placeholder="Value" class="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm text-charcoal placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<button type="button" onclick="iamRemoveRow(this)" class="p-2 text-charcoal-300 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Remove">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>`;
container.appendChild(row);
}
function iamCollectTags(containerId) {
const container = document.getElementById(containerId);
if (!container) return [];
return Array.from(container.querySelectorAll('[data-iam-row="tag"]'))
.map(row => ({
Key: row.querySelector('[data-tag-key]').value.trim(),
Value: row.querySelector('[data-tag-value]').value.trim()
}))
.filter(tag => tag.Key);
}
function iamAddTextRow(containerId, value = '', placeholder = '', maxlength = 255) {
const container = document.getElementById(containerId);
if (!container) return;
const row = document.createElement('div');
row.className = 'flex items-center gap-2';
row.setAttribute('data-iam-row', 'text');
row.innerHTML = `<input type="text" data-row-value value="${escapeHtml(value)}" maxlength="${maxlength}" placeholder="${escapeHtml(placeholder)}" class="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono text-charcoal placeholder:font-sans placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<button type="button" onclick="iamRemoveRow(this)" class="p-2 text-charcoal-300 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Remove">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>`;
container.appendChild(row);
}
function iamCollectTextRows(containerId) {
const container = document.getElementById(containerId);
if (!container) return [];
return Array.from(container.querySelectorAll('[data-iam-row="text"] [data-row-value]'))
.map(input => input.value.trim())
.filter(Boolean);
}
// ============================================
// Policy editor (identity and trust variants)
// ============================================
const IAM_POLICY_VARIANTS = {
identity: {
infoTitle: 'About Inline Identity Policies',
infoBody: 'An inline policy grants the identity it is attached to permission to call specific actions on specific resources. The principal is implicit, so an identity policy must not contain a <strong>Principal</strong> field.',
reference: `<div>
<p class="font-medium text-charcoal mb-1">Common Actions:</p>
<ul class="space-y-0.5 text-charcoal-400">
<li>&bull; <code class="bg-white px-1 py-0.5 rounded">iam:GetUser</code> - Read own or another user</li>
<li>&bull; <code class="bg-white px-1 py-0.5 rounded">iam:ListAccessKeys</code> - List a user's keys</li>
<li>&bull; <code class="bg-white px-1 py-0.5 rounded">iam:CreateAccessKey</code> - Rotate credentials</li>
<li>&bull; <code class="bg-white px-1 py-0.5 rounded">s3:GetObject</code> - Read objects</li>
</ul>
</div>
<div>
<p class="font-medium text-charcoal mb-1">Resource Format:</p>
<ul class="space-y-0.5 text-charcoal-400">
<li>&bull; User: <code class="bg-white px-1 py-0.5 rounded text-[10px]">arn:aws:iam::${IAM_ACCOUNT_ID}:user/alice</code></li>
<li>&bull; Role: <code class="bg-white px-1 py-0.5 rounded text-[10px]">arn:aws:iam::${IAM_ACCOUNT_ID}:role/reader</code></li>
<li>&bull; Objects: <code class="bg-white px-1 py-0.5 rounded text-[10px]">arn:aws:s3:::bucket/*</code></li>
</ul>
</div>`,
help: `<div>
<p class="font-medium mb-1">Required Fields:</p>
<ul class="list-disc list-inside space-y-1 text-charcoal-400 ml-2">
<li><strong>Version:</strong> Always "2012-10-17"</li>
<li><strong>Statement:</strong> Array of policy statements</li>
</ul>
</div>
<div>
<p class="font-medium mb-1">Statement Fields:</p>
<ul class="list-disc list-inside space-y-1 text-charcoal-400 ml-2">
<li><strong>Sid:</strong> Statement identifier (optional, but recommended)</li>
<li><strong>Effect:</strong> "Allow" or "Deny"</li>
<li><strong>Action:</strong> Array of actions (e.g., ["iam:GetUser"])</li>
<li><strong>Resource:</strong> Array of ARNs the actions apply to</li>
<li><strong>Principal:</strong> Not allowed in an identity policy</li>
</ul>
</div>
<div>
<p class="font-medium mb-1">Authorization Model:</p>
<p class="text-charcoal-400 ml-2">Each action is resolved as <code class="bg-white px-1 py-0.5 rounded">iam:&lt;ActionName&gt;</code> against the concrete target ARN. There is no implicit self-access: a user with no inline policy cannot call GetUser even on itself.</p>
</div>`,
example: {
Version: '2012-10-17',
Statement: [
{
Sid: 'ReadOwnIdentity',
Effect: 'Allow',
Action: ['iam:GetUser', 'iam:ListAccessKeys'],
Resource: [`arn:aws:iam::${IAM_ACCOUNT_ID}:user/alice`]
},
{
Sid: 'ReadObjects',
Effect: 'Allow',
Action: ['s3:GetObject'],
Resource: ['arn:aws:s3:::example-bucket/*']
}
]
}
},
trust: {
infoTitle: 'About Trust Policies',
infoBody: 'A trust policy states who may assume this role. <strong>Principal</strong> is required, <strong>Resource</strong> is not allowed, and every action must be <code>sts:</code>-prefixed. Shared identity providers are additionally tenancy-scoped by the server; it will describe the rule if the document violates it.',
reference: `<div>
<p class="font-medium text-charcoal mb-1">Actions:</p>
<ul class="space-y-0.5 text-charcoal-400">
<li>&bull; <code class="bg-white px-1 py-0.5 rounded">sts:AssumeRole</code></li>
<li>&bull; <code class="bg-white px-1 py-0.5 rounded">sts:AssumeRoleWithWebIdentity</code></li>
</ul>
</div>
<div>
<p class="font-medium text-charcoal mb-1">Principal Keys:</p>
<ul class="space-y-0.5 text-charcoal-400">
<li>&bull; <code class="bg-white px-1 py-0.5 rounded">AWS</code> - a user ARN in this account</li>
<li>&bull; <code class="bg-white px-1 py-0.5 rounded">Federated</code> - an OIDC provider ARN</li>
<li>&bull; <code class="bg-white px-1 py-0.5 rounded">Service</code> - a service principal</li>
</ul>
</div>`,
help: `<div>
<p class="font-medium mb-1">Required Fields:</p>
<ul class="list-disc list-inside space-y-1 text-charcoal-400 ml-2">
<li><strong>Version:</strong> Always "2012-10-17"</li>
<li><strong>Statement:</strong> Array of trust statements</li>
<li><strong>Principal:</strong> Object with AWS, Service or Federated keys only</li>
<li><strong>Action:</strong> sts: actions only</li>
</ul>
</div>
<div>
<p class="font-medium mb-1">Not Allowed:</p>
<ul class="list-disc list-inside space-y-1 text-charcoal-400 ml-2">
<li><strong>Resource</strong> and <strong>NotResource</strong> - the role itself is the resource</li>
<li>Non-sts actions</li>
</ul>
</div>
<div>
<p class="font-medium mb-1">Federated Providers:</p>
<p class="text-charcoal-400 ml-2">Trust statements naming a shared provider (GitHub Actions, GitLab and similar) must scope the condition to your own tenancy. The server validates this and returns a descriptive error when the scoping is missing.</p>
</div>`,
example: {
Version: '2012-10-17',
Statement: [
{
Sid: 'AllowUserToAssume',
Effect: 'Allow',
Principal: { AWS: `arn:aws:iam::${IAM_ACCOUNT_ID}:user/example` },
Action: ['sts:AssumeRole']
}
]
}
}
};
const iamPolicyEditor = {
_state: null,
_ensureModal() {
if (document.getElementById('iam-policy-modal')) return;
const wrapper = document.createElement('div');
wrapper.id = 'iam-policy-modal';
wrapper.className = 'modal hidden fixed inset-0 z-50';
wrapper.innerHTML = `<div class="modal-backdrop absolute inset-0" onclick="iamPolicyEditor.close()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-5xl relative max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
<div>
<h2 id="iam-policy-title" class="text-xl font-semibold text-charcoal">Inline Policy</h2>
<p id="iam-policy-subtitle" class="text-sm text-charcoal-300 mt-1"></p>
</div>
<button onclick="iamPolicyEditor.close()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="flex-1 overflow-auto">
<div class="p-6 space-y-6">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div class="text-sm text-blue-800">
<p id="iam-policy-info-title" class="font-medium"></p>
<p id="iam-policy-info-body" class="mt-1"></p>
</div>
</div>
</div>
<div id="iam-policy-name-row" class="hidden">
<label class="block text-sm font-medium text-charcoal mb-2">Policy Name <span class="text-red-500">*</span></label>
<input type="text" id="iam-policy-name" maxlength="64" placeholder="e.g., read-own-keys" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:font-sans placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<p class="mt-2 text-xs text-charcoal-300">Up to 64 characters. Letters, numbers and + = , . @ _ - only. The name cannot be changed after saving.</p>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h3 class="text-sm font-semibold text-charcoal mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Quick Reference
</h3>
<div id="iam-policy-reference" class="grid grid-cols-2 gap-4 text-xs"></div>
</div>
<div>
<div class="flex items-center justify-between mb-3">
<label class="text-sm font-medium text-charcoal">Policy Document</label>
<div class="flex gap-2">
<button onclick="iamPolicyEditor.toggleHelp()" class="inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Help
</button>
<button onclick="iamPolicyEditor.loadExample()" class="inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Load Example
</button>
<button onclick="iamPolicyEditor.validate(true)" class="inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 rounded-lg transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Validate
</button>
</div>
</div>
<div id="iam-policy-status" class="mb-3 hidden"></div>
<textarea id="iam-policy-editor-json" rows="16" oninput="iamPolicyEditor.updateCounter()" placeholder="No policy defined. Click 'Load Example' to get started." class="w-full px-4 py-3 border-2 border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:border-accent resize-none"></textarea>
<p id="iam-policy-counter" class="mt-2 text-xs text-charcoal-300"></p>
</div>
<div id="iam-policy-help" class="hidden bg-gray-50 border border-gray-200 rounded-lg p-4">
<div class="flex items-start justify-between mb-3">
<h3 class="text-sm font-semibold text-charcoal flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
Policy Structure Guide
</h3>
<button onclick="iamPolicyEditor.toggleHelp()" class="text-charcoal-300 hover:text-charcoal">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div id="iam-policy-help-body" class="space-y-3 text-sm text-charcoal"></div>
</div>
</div>
</div>
<div class="flex items-center justify-between p-6 border-t border-gray-100 flex-shrink-0">
<button id="iam-policy-delete-btn" onclick="iamPolicyEditor.remove()" class="hidden px-4 py-2.5 border border-red-200 text-red-600 hover:bg-red-50 font-medium rounded-lg transition-colors">Delete Policy</button>
<div class="flex gap-3 ml-auto">
<button onclick="iamPolicyEditor.close()" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button id="iam-policy-save-btn" onclick="iamPolicyEditor.save()" class="px-4 py-2.5 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">Save Policy</button>
</div>
</div>
</div>
</div>`;
document.body.appendChild(wrapper);
},
/**
* @param {Object} opts
* variant 'identity' | 'trust'
* title modal heading
* subtitle the identity this document belongs to
* policyName existing name ('' for a new policy)
* nameEditable show and require the policy-name field
* document initial JSON text
* quota { otherBytes, max } for the aggregate inline-policy counter
* maxBytes hard limit for this single document
* showDelete render the Delete Policy button
* saveLabel label for the save button (default 'Save Policy')
* onSave async ({ policyName, document }) => void
* onDelete async () => void
*/
open(opts) {
this._ensureModal();
const variant = IAM_POLICY_VARIANTS[opts.variant] || IAM_POLICY_VARIANTS.identity;
this._state = Object.assign({ variant: 'identity' }, opts);
document.getElementById('iam-policy-title').textContent = opts.title || 'Policy';
document.getElementById('iam-policy-subtitle').textContent = opts.subtitle || '';
document.getElementById('iam-policy-info-title').textContent = variant.infoTitle;
document.getElementById('iam-policy-info-body').innerHTML = variant.infoBody;
document.getElementById('iam-policy-reference').innerHTML = variant.reference;
document.getElementById('iam-policy-help-body').innerHTML = variant.help;
document.getElementById('iam-policy-help').classList.add('hidden');
const nameRow = document.getElementById('iam-policy-name-row');
const nameInput = document.getElementById('iam-policy-name');
nameRow.classList.toggle('hidden', !opts.nameEditable);
nameInput.value = opts.policyName || '';
const editor = document.getElementById('iam-policy-editor-json');
editor.value = opts.document || '';
document.getElementById('iam-policy-delete-btn').classList.toggle('hidden', !opts.showDelete);
document.getElementById('iam-policy-save-btn').textContent = opts.saveLabel || 'Save Policy';
this._setStatus(null);
this.updateCounter();
openModal('iam-policy-modal');
},
close() {
this._state = null;
closeModal('iam-policy-modal');
},
toggleHelp() {
document.getElementById('iam-policy-help').classList.toggle('hidden');
},
loadExample() {
const variant = IAM_POLICY_VARIANTS[this._state?.variant] || IAM_POLICY_VARIANTS.identity;
document.getElementById('iam-policy-editor-json').value = JSON.stringify(variant.example, null, 2);
this._setStatus(null);
this.updateCounter();
},
updateCounter() {
const state = this._state;
if (!state) return;
const bytes = iamByteLength(document.getElementById('iam-policy-editor-json').value);
const counter = document.getElementById('iam-policy-counter');
const quota = state.quota;
if (quota) {
const total = (quota.otherBytes || 0) + bytes;
const over = total > quota.max;
counter.className = 'mt-2 text-xs ' + (over ? 'text-red-600 font-medium' : 'text-charcoal-300');
counter.textContent = `${total} / ${quota.max} bytes used across this identity's inline policies (this document: ${bytes} bytes)`;
} else {
const max = state.maxBytes || IAM_LIMITS.policyDocumentBytes;
const over = bytes > max;
counter.className = 'mt-2 text-xs ' + (over ? 'text-red-600 font-medium' : 'text-charcoal-300');
counter.textContent = `${bytes} / ${max} bytes`;
}
},
_setStatus(message, type = 'error') {
const el = document.getElementById('iam-policy-status');
if (!message) {
el.classList.add('hidden');
el.innerHTML = '';
return;
}
const styles = {
error: 'bg-red-50 border-red-200 text-red-800',
success: 'bg-green-50 border-green-200 text-green-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800'
};
el.className = `mb-3 border rounded-lg px-4 py-3 text-sm ${styles[type]}`;
el.textContent = message;
el.classList.remove('hidden');
},
/**
* Client-side grammar check. Deliberately shallow: it catches the two
* grammars' hard rules and leaves everything else to the server's own
* descriptive errors.
*/
validate(announce = false) {
const state = this._state;
if (!state) return null;
const text = document.getElementById('iam-policy-editor-json').value.trim();
if (!text) {
this._setStatus('Policy document is empty.');
return null;
}
let parsed;
try {
parsed = JSON.parse(text);
} catch (e) {
this._setStatus('Invalid JSON: ' + e.message);
return null;
}
const errors = [];
if (!parsed.Version) errors.push('Missing "Version" (use "2012-10-17").');
const statements = Array.isArray(parsed.Statement) ? parsed.Statement : (parsed.Statement ? [parsed.Statement] : []);
if (statements.length === 0) errors.push('"Statement" must be a non-empty array.');
statements.forEach((st, i) => {
const at = `Statement ${i + 1}`;
if (!st || typeof st !== 'object') { errors.push(`${at} must be an object.`); return; }
if (st.Effect !== 'Allow' && st.Effect !== 'Deny') errors.push(`${at}: "Effect" must be "Allow" or "Deny".`);
const actions = [].concat(st.Action || st.NotAction || []);
if (actions.length === 0) errors.push(`${at}: "Action" is required.`);
if (state.variant === 'trust') {
if (!st.Principal || typeof st.Principal !== 'object' || Array.isArray(st.Principal)) {
errors.push(`${at}: "Principal" is required and must be an object with AWS, Service or Federated keys.`);
} else {
const allowed = ['AWS', 'Service', 'Federated'];
Object.keys(st.Principal).forEach(key => {
if (!allowed.includes(key)) errors.push(`${at}: "Principal.${key}" is not allowed. Use AWS, Service or Federated.`);
});
}
if ('Resource' in st || 'NotResource' in st) errors.push(`${at}: "Resource" is not allowed in a trust policy.`);
actions.forEach(action => {
if (typeof action === 'string' && !action.startsWith('sts:')) errors.push(`${at}: "${action}" is not an sts: action.`);
});
} else {
if ('Principal' in st || 'NotPrincipal' in st) errors.push(`${at}: "Principal" is not allowed in an identity policy - the identity it is attached to is the principal.`);
const resources = [].concat(st.Resource || st.NotResource || []);
if (resources.length === 0) errors.push(`${at}: "Resource" is required.`);
}
});
const bytes = iamByteLength(text);
const maxBytes = state.maxBytes || IAM_LIMITS.policyDocumentBytes;
if (bytes > maxBytes) errors.push(`Document is ${bytes} bytes, over the ${maxBytes}-byte limit.`);
if (state.quota && (state.quota.otherBytes || 0) + bytes > state.quota.max) {
errors.push(`This identity's inline policies would total ${(state.quota.otherBytes || 0) + bytes} bytes, over the ${state.quota.max}-byte aggregate limit.`);
}
if (errors.length > 0) {
this._setStatus(errors.join(' '));
return null;
}
if (announce) this._setStatus('Policy document is valid.', 'success');
else this._setStatus(null);
return JSON.stringify(parsed);
},
async save() {
const state = this._state;
if (!state) return;
let policyName = state.policyName || '';
if (state.nameEditable) {
policyName = document.getElementById('iam-policy-name').value.trim();
const nameError = iamValidateName(policyName, 'Policy name');
if (nameError) { this._setStatus(nameError); return; }
}
const document_ = this.validate();
if (!document_) return;
const btn = document.getElementById('iam-policy-save-btn');
setLoading(btn, true);
try {
await state.onSave({ policyName, document: document_ });
this.close();
} catch (error) {
console.error('Error saving policy:', error);
this._setStatus(iamErrorText(error));
} finally {
setLoading(btn, false);
}
},
async remove() {
const state = this._state;
if (!state || !state.onDelete) return;
const btn = document.getElementById('iam-policy-delete-btn');
setLoading(btn, true);
try {
await state.onDelete();
this.close();
} catch (error) {
console.error('Error deleting policy:', error);
this._setStatus(iamErrorText(error));
} finally {
setLoading(btn, false);
}
}
};
+21 -11
View File
@@ -50,35 +50,45 @@ under the License.
</a>
</div>
<nav class="flex-1 py-4">
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-admin-only>
Admin
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-management-only>
Management
</div>
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
<span class="font-medium">Dashboard</span>
</a>
<a href="users.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-admin-only>
<a href="users.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-admin-only data-admin-users-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<span class="font-medium">Users</span>
</a>
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
<span class="font-medium">Buckets</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-admin-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<div class="mx-6 my-2 border-t border-white/10" data-management-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-s3-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span class="font-medium">Explorer</span>
</a>
<div class="mx-6 my-2 border-t border-white/10"></div>
<div class="mx-6 my-2 border-t border-white/10" data-s3-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-iam-only>
Identity &amp; Access
</div>
<a href="iam.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-iam-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span class="font-medium">IAM</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-iam-only></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
Resources
</div>
@@ -88,13 +98,13 @@ under the License.
</svg>
<span class="font-medium">Documentation</span>
</a>
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span class="font-medium">Bug Reports</span>
</a>
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-management-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
@@ -390,7 +400,7 @@ under the License.
dropdown.classList.remove('show');
}
if (!requireAdmin()) {
if (!requireGatewayUsers()) {
// Redirected
} else {
initSidebarWithRole();
+34 -2
View File
@@ -23,6 +23,7 @@ import (
"strings"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/etag"
"github.com/gofiber/fiber/v3/middleware/logger"
"github.com/gofiber/fiber/v3/middleware/recover"
"github.com/gofiber/fiber/v3/middleware/static"
@@ -33,8 +34,13 @@ import (
type ServerConfig struct {
Gateways []string // S3 API gateways
AdminGateways []string // Admin API gateways (defaults to Gateways if empty)
Region string
CORSOrigin string
// IAMGateways are standalone IAM service (versitygw iam) endpoints, used
// to seed the WebUI's optional IAM endpoint field. Unlike AdminGateways
// there is no fallback to Gateways: the IAM service is a separate
// process, so empty means "no IAM endpoint to offer".
IAMGateways []string
Region string
CORSOrigin string
}
// Server is the main GUI server
@@ -115,6 +121,11 @@ func (s *Server) setupMiddleware() {
func (s *Server) setupRoutes() error {
prefix := s.pathPrefix
// Must come before the routes it applies to: a Use() registered after a
// matching route never runs, since those handlers don't call Next().
s.app.Use(prefix+"/", s.revalidateAssets)
s.app.Use(prefix+"/", etag.New())
// Serve index.html with server-side config injection
s.app.Get(prefix+"/", s.handleIndexHTML)
s.app.Get(prefix+"/index.html", s.handleIndexHTML)
@@ -138,6 +149,26 @@ func (s *Server) setupRoutes() error {
return nil
}
// revalidateAssets makes browsers check back with the gateway before reusing a
// cached copy of the web UI.
//
// The UI is embedded in the binary, so its files go out stamped with the zero
// modification time. With no Cache-Control to go on, a browser's heuristic
// freshness rule turns that apparent age into a centuries-long expiry, and an
// upgraded gateway ends up serving new HTML against stale cached assets.
// no-cache keeps the copy but forces revalidation, which the ETag middleware
// answers with a 304 when the file has not changed.
func (s *Server) revalidateAssets(c fiber.Ctx) error {
if err := c.Next(); err != nil {
return err
}
c.Response().Header.Del(fiber.HeaderLastModified)
c.Set(fiber.HeaderCacheControl, "no-cache")
return nil
}
// handleIndexHTML serves index.html with server config injected as an inline script.
func (s *Server) handleIndexHTML(c fiber.Ctx) error {
data, err := webFiles.ReadFile("web/index.html")
@@ -153,6 +184,7 @@ func (s *Server) handleIndexHTML(c fiber.Ctx) error {
configJSON, err := json.Marshal(map[string]any{
"gateways": s.config.Gateways,
"adminGateways": adminGateways,
"iamGateways": s.config.IAMGateways,
"defaultRegion": s.config.Region,
})
if err != nil {