mirror of
https://github.com/versity/versitygw.git
synced 2026-08-29 04:06:56 +00:00
Merge pull request #2308 from versity/sis/iam-webui
feat: add standalone IAM support in WebGUI
This commit is contained in:
+32
-7
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:*"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ apiVersion: v2
|
||||
name: versitygw
|
||||
description: A Helm chart for deploying the Versity S3 Gateway on Kubernetes
|
||||
type: application
|
||||
version: 0.4.0
|
||||
version: 0.4.1
|
||||
sources:
|
||||
- https://github.com/versity/versitygw
|
||||
icon: https://raw.githubusercontent.com/versity/versitygw/main/webui/web/assets/images/Versity-logo-blue-horizontal.png
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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{},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -2002,6 +2003,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ const (
|
||||
actS3DeleteObjectVersion = "s3:DeleteObjectVersion"
|
||||
actS3ListBucket = "s3:ListBucket"
|
||||
actS3CreateBucket = "s3:CreateBucket"
|
||||
actS3ListAllMyBuckets = "s3:ListAllMyBuckets"
|
||||
actS3BypassGovernance = "s3:BypassGovernanceRetention"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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 & 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
@@ -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 & 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 don’t 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
@@ -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 & 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) {
|
||||
|
||||
@@ -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 & 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>
|
||||
@@ -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 & 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 — 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–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>
|
||||
@@ -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 & 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>
|
||||
@@ -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 & 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 & 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
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -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>• <code class="bg-white px-1 py-0.5 rounded">iam:GetUser</code> - Read own or another user</li>
|
||||
<li>• <code class="bg-white px-1 py-0.5 rounded">iam:ListAccessKeys</code> - List a user's keys</li>
|
||||
<li>• <code class="bg-white px-1 py-0.5 rounded">iam:CreateAccessKey</code> - Rotate credentials</li>
|
||||
<li>• <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>• User: <code class="bg-white px-1 py-0.5 rounded text-[10px]">arn:aws:iam::${IAM_ACCOUNT_ID}:user/alice</code></li>
|
||||
<li>• Role: <code class="bg-white px-1 py-0.5 rounded text-[10px]">arn:aws:iam::${IAM_ACCOUNT_ID}:role/reader</code></li>
|
||||
<li>• 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:<ActionName></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>• <code class="bg-white px-1 py-0.5 rounded">sts:AssumeRole</code></li>
|
||||
<li>• <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>• <code class="bg-white px-1 py-0.5 rounded">AWS</code> - a user ARN in this account</li>
|
||||
<li>• <code class="bg-white px-1 py-0.5 rounded">Federated</code> - an OIDC provider ARN</li>
|
||||
<li>• <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
@@ -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 & 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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user