mirror of
https://github.com/versity/versitygw.git
synced 2026-09-28 10:46:03 +00:00
feat: add standalone IAM support in WebGUI
Gates bucket listing behind an identity policy, lets browsers reach the standalone IAM API, and turns the WebUI into a dashboard for S3, IAM, or both.
**Bucket listing.** `ListBuckets` is now gated by the new `s3:ListAllMyBuckets` action, evaluated against `arn:aws:s3:::*`. The request names no bucket, so only identity policies apply — there is no resource policy to combine with, which is the same shape `CreateBucket` already had, so both now share one identity-only evaluation path. Root and admin bypass it, and backends with no identity-policy layer keep listing as before since their listing is already narrowed to the caller's own buckets. The action is IAM-only and is deliberately absent from the bucket-policy action list.
**Fixed bucket ownership.** The standalone IAM client has no per-user ownership to express — accounts are all plain users, cannot be enumerated, and access is decided by policy rather than ACL — so it now implements `auth.FixedBucketOwner` and every bucket is owned by root. Bucket creation stops resolving an owner, `ListBuckets` returns every bucket to every caller (what they may then do with one stays a per-request policy decision), and the admin `ChangeBucketOwner` reports method-not-supported. Other IAM backends are untouched.
**IAM service CORS.** `--cors-allow-origin` now applies to the `iam` command: it answers preflights and stamps the CORS headers, mirroring back the requested method and headers rather than enumerating the SigV4 header set. Without it no browser can reach the IAM API at all, so setting `--webui` without it falls back to `*` with a warning. The chart gets `iamServer.corsAllowOrigin`.
**WebUI.** New IAM pages for users, roles and OIDC providers, signing IAM/STS query-form requests directly from the browser. Navigation is capability-gated rather than role-gated: on sign-in the session probes the S3, admin and IAM endpoints independently and each page shows only what those credentials actually reach, so one build serves an IAM-only dashboard, an S3-only dashboard, and a combined one. The login page takes an optional IAM endpoint, seeded from the new `--webui-iam-gateways` (chart: `webui.iamGateways`) — never auto-detected, since the IAM service is a separate process. The WebUI can also be hosted by `versitygw iam` itself, for deployments with no S3 gateway behind it.
**The admin API is ignored once an IAM endpoint is in play.** The IAM service is then the user directory and bucket ownership is fixed, which leaves the admin API no job: the session is given no admin endpoint at all, its login field is hidden, `users.html` redirects to its IAM counterpart, and every admin-only surface stays off screen. Dashboard and Buckets remain available to any S3 session in such a deployment, running on the S3 and IAM APIs alone and surfacing each denial per action instead of redirecting.
Also fixes two WebUI bugs: embedded assets went out with a zero modification time and no `Cache-Control`, so browsers treated them as fresh for centuries and an upgraded gateway served new HTML against stale JS — they now revalidate against an ETag; and the login page's advanced-options section clipped its last field, since it animated to a height named in the stylesheet rather than the one it measures now.
**Usage**
IAM-only dashboard, served by the IAM service:
versitygw iam --port :7076 --webui :8080 --cors-allow-origin http://localhost:8080/
IAM + S3, dashboard served by the IAM service — point it at the gateway with `--webui-gateways`, and let the gateway accept the dashboard's origin:
versitygw iam --port :7076 --webui :8080 --webui-gateways http://localhost:7070/ --cors-allow-origin http://localhost:8080/
versitygw --port :7070 --cors-allow-origin http://localhost:8080/ posix /data
IAM + S3, dashboard served by the S3 gateway — point it at the IAM service with `--webui-iam-gateways`, and let the IAM service accept the dashboard's origin:
versitygw --port :7070 --webui :8080 --webui-iam-gateways http://localhost:7076/ posix /data
versitygw iam --port :7076 --cors-allow-origin http://localhost:8080/
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user