fix auth model
This commit is contained in:
1 parent
f81285b151
commit
5e3800e9cb
10 files changed
+901
-70
No files matched your search
@@ -39,14 +39,27 @@ type DatabaseConfig struct {
|
||||
// OpenStack admin credential from at startup. Mode "mock" skips all of this
|
||||
// and uses an in-memory FloatingIPClient instead — used for local dev and
|
||||
// the offline end-to-end harness.
|
||||
//
|
||||
// AuthMethod selects which credential shape is expected in the process
|
||||
// environment: "token" (default) reads a pre-issued, already
|
||||
// project-scoped token from TokenEnv and uses it as-is (no renewal — the
|
||||
// operator reissues and restarts on expiry). "password" reads
|
||||
// username/password/domain from UsernameEnv/PasswordEnv/UserDomainNameEnv
|
||||
// and has the client obtain and auto-renew its own token.
|
||||
type OpenStackConfig struct {
|
||||
Mode string `yaml:"mode"` // "mock" | "real"
|
||||
AuthURLEnv string `yaml:"auth_url_env"`
|
||||
TokenEnv string `yaml:"token_env"`
|
||||
ProjectIDEnv string `yaml:"project_id_env"`
|
||||
ProjectNameEnv string `yaml:"project_name_env"`
|
||||
ProjectDomainEnv string `yaml:"project_domain_env"`
|
||||
RegionEnv string `yaml:"region_env"`
|
||||
Mode string `yaml:"mode"` // "mock" | "real"
|
||||
AuthMethod string `yaml:"auth_method"` // "token" (default) | "password"
|
||||
|
||||
AuthURLEnv string `yaml:"auth_url_env"` // default OS_AUTH_URL
|
||||
ProjectIDEnv string `yaml:"project_id_env"` // default OS_PROJECT_ID
|
||||
RegionEnv string `yaml:"region_env"` // default OS_REGION_NAME
|
||||
InterfaceEnv string `yaml:"interface_env"` // default OS_INTERFACE; "" at runtime defaults to "public"
|
||||
|
||||
TokenEnv string `yaml:"token_env"` // default OS_TOKEN — used when auth_method: token
|
||||
|
||||
UsernameEnv string `yaml:"username_env"` // default OS_USERNAME — used when auth_method: password
|
||||
UserDomainNameEnv string `yaml:"user_domain_name_env"` // default OS_USER_DOMAIN_NAME
|
||||
PasswordEnv string `yaml:"password_env"` // default OS_PASSWORD
|
||||
}
|
||||
|
||||
type OrchestratorConfig struct {
|
||||
@@ -101,6 +114,33 @@ func LoadControlAPI(path string) (*ControlAPI, error) {
|
||||
if c.OpenStack.Mode == "" {
|
||||
c.OpenStack.Mode = "mock"
|
||||
}
|
||||
if c.OpenStack.AuthMethod == "" {
|
||||
c.OpenStack.AuthMethod = "token"
|
||||
}
|
||||
if c.OpenStack.AuthURLEnv == "" {
|
||||
c.OpenStack.AuthURLEnv = "OS_AUTH_URL"
|
||||
}
|
||||
if c.OpenStack.ProjectIDEnv == "" {
|
||||
c.OpenStack.ProjectIDEnv = "OS_PROJECT_ID"
|
||||
}
|
||||
if c.OpenStack.RegionEnv == "" {
|
||||
c.OpenStack.RegionEnv = "OS_REGION_NAME"
|
||||
}
|
||||
if c.OpenStack.InterfaceEnv == "" {
|
||||
c.OpenStack.InterfaceEnv = "OS_INTERFACE"
|
||||
}
|
||||
if c.OpenStack.TokenEnv == "" {
|
||||
c.OpenStack.TokenEnv = "OS_TOKEN"
|
||||
}
|
||||
if c.OpenStack.UsernameEnv == "" {
|
||||
c.OpenStack.UsernameEnv = "OS_USERNAME"
|
||||
}
|
||||
if c.OpenStack.UserDomainNameEnv == "" {
|
||||
c.OpenStack.UserDomainNameEnv = "OS_USER_DOMAIN_NAME"
|
||||
}
|
||||
if c.OpenStack.PasswordEnv == "" {
|
||||
c.OpenStack.PasswordEnv = "OS_PASSWORD"
|
||||
}
|
||||
if c.Orchestrator.PollIntervalSeconds == 0 {
|
||||
c.Orchestrator.PollIntervalSeconds = 5
|
||||
}
|
||||
|
||||
+100
-28
@@ -9,44 +9,109 @@ import (
|
||||
"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips"
|
||||
)
|
||||
|
||||
// AuthMethod selects how the client obtains the token it uses for Neutron
|
||||
// calls.
|
||||
type AuthMethod string
|
||||
|
||||
const (
|
||||
// AuthMethodToken uses an admin-supplied token as-is (passthrough): the
|
||||
// token is validated against Keystone but never exchanged for a freshly
|
||||
// minted one, and the exact token value the operator provided is what
|
||||
// every subsequent Neutron call uses. This is the default — it matches
|
||||
// the deployment constraint that the admin credential is supplied
|
||||
// directly via the process environment. Its trade-off: there is no way
|
||||
// to renew the token automatically, since nothing longer-lived than the
|
||||
// token itself is available to re-authenticate with. When it expires,
|
||||
// the operator must reissue it and restart control-api.
|
||||
AuthMethodToken AuthMethod = "token"
|
||||
|
||||
// AuthMethodPassword has the client obtain its own token via ordinary
|
||||
// Keystone password authentication, and automatically re-authenticates
|
||||
// (mints a fresh token) whenever the current one is rejected — not
|
||||
// bounded by any single token's TTL, at the cost of holding a
|
||||
// long-lived password credential in the process environment instead of
|
||||
// a token.
|
||||
AuthMethodPassword AuthMethod = "password"
|
||||
)
|
||||
|
||||
// ClientConfig carries pre-resolved credential values (already read from
|
||||
// environment variables by the caller — see config.OpenStackAuth). Token
|
||||
// auth is required per the deployment constraint that the admin credential
|
||||
// is supplied via the process environment, not a config file.
|
||||
// environment variables by the caller — see config.OpenStackConfig). Some
|
||||
// form of admin credential is always required via the process environment,
|
||||
// never a config file; which fields are required depends on Method.
|
||||
type ClientConfig struct {
|
||||
AuthURL string
|
||||
Token string
|
||||
ProjectID string
|
||||
ProjectName string
|
||||
DomainName string
|
||||
Region string
|
||||
AuthURL string
|
||||
Method AuthMethod // "" is treated as AuthMethodToken
|
||||
|
||||
Token string // required when Method == AuthMethodToken
|
||||
|
||||
Username string // required when Method == AuthMethodPassword
|
||||
Password string
|
||||
UserDomainName string
|
||||
|
||||
ProjectID string
|
||||
Region string
|
||||
Interface string // "public" | "internal" | "admin"; "" defaults to "public"
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
networking *gophercloud.ServiceClient
|
||||
}
|
||||
|
||||
// NewClient authenticates against Keystone using a pre-issued admin token
|
||||
// and returns a Client scoped to the given project/region, backed by the
|
||||
// Neutron (networking v2) service catalog entry.
|
||||
func NewClient(ctx context.Context, cfg ClientConfig) (*Client, error) {
|
||||
if cfg.AuthURL == "" || cfg.Token == "" {
|
||||
return nil, fmt.Errorf("openstack: auth URL and token are required")
|
||||
// buildAuthOptions translates ClientConfig into gophercloud.AuthOptions. It
|
||||
// touches no network and returns only validation errors, so the auth-method
|
||||
// selection logic is unit-testable without a real OpenStack deployment.
|
||||
func buildAuthOptions(cfg ClientConfig) (gophercloud.AuthOptions, error) {
|
||||
if cfg.AuthURL == "" {
|
||||
return gophercloud.AuthOptions{}, fmt.Errorf("openstack: auth URL is required")
|
||||
}
|
||||
if cfg.ProjectID == "" {
|
||||
return gophercloud.AuthOptions{}, fmt.Errorf("openstack: project ID is required")
|
||||
}
|
||||
|
||||
authOpts := gophercloud.AuthOptions{
|
||||
IdentityEndpoint: cfg.AuthURL,
|
||||
TokenID: cfg.Token,
|
||||
TenantID: cfg.ProjectID,
|
||||
TenantName: cfg.ProjectName,
|
||||
DomainName: cfg.DomainName,
|
||||
}
|
||||
if cfg.ProjectID != "" || cfg.ProjectName != "" {
|
||||
authOpts.Scope = &gophercloud.AuthScope{
|
||||
ProjectID: cfg.ProjectID,
|
||||
ProjectName: cfg.ProjectName,
|
||||
DomainName: cfg.DomainName,
|
||||
opts := gophercloud.AuthOptions{IdentityEndpoint: cfg.AuthURL}
|
||||
|
||||
switch cfg.Method {
|
||||
case AuthMethodPassword:
|
||||
if cfg.Username == "" || cfg.Password == "" {
|
||||
return gophercloud.AuthOptions{}, fmt.Errorf("openstack: username and password are required for auth_method=password")
|
||||
}
|
||||
opts.Username = cfg.Username
|
||||
opts.Password = cfg.Password
|
||||
opts.DomainName = cfg.UserDomainName
|
||||
opts.Scope = &gophercloud.AuthScope{ProjectID: cfg.ProjectID}
|
||||
// Safe and valuable here: a password credential can always mint a
|
||||
// fresh token, so gophercloud can transparently re-authenticate on
|
||||
// 401 for the entire lifetime of the process.
|
||||
opts.AllowReauth = true
|
||||
|
||||
case AuthMethodToken, "":
|
||||
if cfg.Token == "" {
|
||||
return gophercloud.AuthOptions{}, fmt.Errorf("openstack: token is required for auth_method=token")
|
||||
}
|
||||
opts.TokenID = cfg.Token
|
||||
// Scope is deliberately left unset: gophercloud's v3auth takes this
|
||||
// as the signal to "pass through" the given token rather than
|
||||
// exchange it for a new one (GET /v3/auth/tokens to validate +
|
||||
// fetch the catalog, using the same token value for every
|
||||
// subsequent call, never minting a replacement). AllowReauth is
|
||||
// left false on purpose — gophercloud actively rejects AllowReauth
|
||||
// when Scope is unset, and there's nothing to reauthenticate with
|
||||
// beyond the one token we were given anyway.
|
||||
|
||||
default:
|
||||
return gophercloud.AuthOptions{}, fmt.Errorf("openstack: unknown auth method %q", cfg.Method)
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// NewClient authenticates against Keystone (via the method selected by
|
||||
// cfg.Method) and returns a Client scoped to the given project/region,
|
||||
// backed by the Neutron (networking v2) service catalog entry.
|
||||
func NewClient(ctx context.Context, cfg ClientConfig) (*Client, error) {
|
||||
authOpts, err := buildAuthOptions(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider, err := osauth.AuthenticatedClient(ctx, authOpts)
|
||||
@@ -54,7 +119,14 @@ func NewClient(ctx context.Context, cfg ClientConfig) (*Client, error) {
|
||||
return nil, fmt.Errorf("openstack: authenticate: %w", err)
|
||||
}
|
||||
|
||||
networking, err := osauth.NewNetworkV2(provider, gophercloud.EndpointOpts{Region: cfg.Region})
|
||||
iface := cfg.Interface
|
||||
if iface == "" {
|
||||
iface = string(gophercloud.AvailabilityPublic)
|
||||
}
|
||||
networking, err := osauth.NewNetworkV2(provider, gophercloud.EndpointOpts{
|
||||
Region: cfg.Region,
|
||||
Availability: gophercloud.Availability(iface),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openstack: networking client: %w", err)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,15 @@ import (
|
||||
// vars are set, so the default `go test ./...` run needs no cloud access.
|
||||
// It only exercises GetFloatingIPByAddress (read-only) against
|
||||
// OS_TEST_FLOATING_IP, to avoid mutating real infrastructure in CI.
|
||||
//
|
||||
// OS_AUTH_METHOD selects which credential shape to test: "token" (default)
|
||||
// reads OS_TOKEN and exercises the passthrough path; "password" reads
|
||||
// OS_USERNAME/OS_PASSWORD/OS_USER_DOMAIN_NAME and exercises the
|
||||
// auto-obtain/auto-reauth path. Run once per method to validate both
|
||||
// against the real deployment.
|
||||
func TestClientLive(t *testing.T) {
|
||||
if os.Getenv("OPENSTACK_LIVE_TEST") != "1" {
|
||||
t.Skip("set OPENSTACK_LIVE_TEST=1 (and OS_AUTH_URL, OS_TOKEN, OS_TEST_FLOATING_IP) to run")
|
||||
t.Skip("set OPENSTACK_LIVE_TEST=1 (and OS_AUTH_URL, OS_PROJECT_ID, OS_TEST_FLOATING_IP, plus either OS_TOKEN or OS_USERNAME/OS_PASSWORD/OS_USER_DOMAIN_NAME) to run")
|
||||
}
|
||||
|
||||
testIP := os.Getenv("OS_TEST_FLOATING_IP")
|
||||
@@ -22,19 +28,30 @@ func TestClientLive(t *testing.T) {
|
||||
t.Fatal("OS_TEST_FLOATING_IP must name a floating IP address that exists in the target project")
|
||||
}
|
||||
|
||||
cfg := ClientConfig{
|
||||
AuthURL: os.Getenv("OS_AUTH_URL"),
|
||||
ProjectID: os.Getenv("OS_PROJECT_ID"),
|
||||
Region: os.Getenv("OS_REGION_NAME"),
|
||||
Interface: os.Getenv("OS_INTERFACE"),
|
||||
}
|
||||
|
||||
switch os.Getenv("OS_AUTH_METHOD") {
|
||||
case "password":
|
||||
cfg.Method = AuthMethodPassword
|
||||
cfg.Username = os.Getenv("OS_USERNAME")
|
||||
cfg.Password = os.Getenv("OS_PASSWORD")
|
||||
cfg.UserDomainName = os.Getenv("OS_USER_DOMAIN_NAME")
|
||||
default:
|
||||
cfg.Method = AuthMethodToken
|
||||
cfg.Token = os.Getenv("OS_TOKEN")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := NewClient(ctx, ClientConfig{
|
||||
AuthURL: os.Getenv("OS_AUTH_URL"),
|
||||
Token: os.Getenv("OS_TOKEN"),
|
||||
ProjectID: os.Getenv("OS_PROJECT_ID"),
|
||||
ProjectName: os.Getenv("OS_PROJECT_NAME"),
|
||||
DomainName: os.Getenv("OS_PROJECT_DOMAIN_NAME"),
|
||||
Region: os.Getenv("OS_REGION_NAME"),
|
||||
})
|
||||
client, err := NewClient(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("new client: %v", err)
|
||||
t.Fatalf("new client (method=%s): %v", cfg.Method, err)
|
||||
}
|
||||
|
||||
fip, err := client.GetFloatingIPByAddress(ctx, testIP)
|
||||
@@ -44,5 +61,5 @@ func TestClientLive(t *testing.T) {
|
||||
if fip.Address != testIP {
|
||||
t.Fatalf("expected address %s, got %s", testIP, fip.Address)
|
||||
}
|
||||
t.Logf("found floating ip %s: id=%s port_id=%q", fip.Address, fip.ID, fip.PortID)
|
||||
t.Logf("found floating ip %s: id=%s port_id=%q (auth method=%s)", fip.Address, fip.ID, fip.PortID, cfg.Method)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package openstack
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildAuthOptionsToken(t *testing.T) {
|
||||
opts, err := buildAuthOptions(ClientConfig{
|
||||
AuthURL: "https://keystone.example:5000/v3/",
|
||||
Method: AuthMethodToken,
|
||||
Token: "tok-123",
|
||||
ProjectID: "proj-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if opts.TokenID != "tok-123" {
|
||||
t.Fatalf("expected TokenID to be passed through, got %q", opts.TokenID)
|
||||
}
|
||||
if opts.Scope != nil {
|
||||
t.Fatalf("expected Scope to be unset for token passthrough, got %+v", opts.Scope)
|
||||
}
|
||||
if opts.AllowReauth {
|
||||
t.Fatalf("expected AllowReauth=false for token passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthOptionsTokenDefaultMethod(t *testing.T) {
|
||||
// Method: "" must behave identically to AuthMethodToken.
|
||||
opts, err := buildAuthOptions(ClientConfig{
|
||||
AuthURL: "https://keystone.example:5000/v3/",
|
||||
Token: "tok-123",
|
||||
ProjectID: "proj-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if opts.TokenID != "tok-123" || opts.Scope != nil {
|
||||
t.Fatalf("expected default method to behave as token passthrough, got %+v", opts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthOptionsTokenMissing(t *testing.T) {
|
||||
_, err := buildAuthOptions(ClientConfig{
|
||||
AuthURL: "https://keystone.example:5000/v3/",
|
||||
Method: AuthMethodToken,
|
||||
ProjectID: "proj-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthOptionsPassword(t *testing.T) {
|
||||
opts, err := buildAuthOptions(ClientConfig{
|
||||
AuthURL: "https://keystone.example:5000/v3/",
|
||||
Method: AuthMethodPassword,
|
||||
Username: "a.yurishchev",
|
||||
Password: "secret",
|
||||
UserDomainName: "users",
|
||||
ProjectID: "proj-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if opts.Username != "a.yurishchev" || opts.Password != "secret" || opts.DomainName != "users" {
|
||||
t.Fatalf("expected password credentials to be passed through, got %+v", opts)
|
||||
}
|
||||
if opts.Scope == nil || opts.Scope.ProjectID != "proj-1" {
|
||||
t.Fatalf("expected scope to be set to project ID, got %+v", opts.Scope)
|
||||
}
|
||||
if !opts.AllowReauth {
|
||||
t.Fatalf("expected AllowReauth=true for password auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthOptionsPasswordMissingCredentials(t *testing.T) {
|
||||
_, err := buildAuthOptions(ClientConfig{
|
||||
AuthURL: "https://keystone.example:5000/v3/",
|
||||
Method: AuthMethodPassword,
|
||||
Username: "a.yurishchev",
|
||||
ProjectID: "proj-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthOptionsUnknownMethod(t *testing.T) {
|
||||
_, err := buildAuthOptions(ClientConfig{
|
||||
AuthURL: "https://keystone.example:5000/v3/",
|
||||
Method: "totp",
|
||||
ProjectID: "proj-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown auth method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthOptionsMissingAuthURL(t *testing.T) {
|
||||
_, err := buildAuthOptions(ClientConfig{
|
||||
Method: AuthMethodToken,
|
||||
Token: "tok-123",
|
||||
ProjectID: "proj-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing auth URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthOptionsMissingProjectID(t *testing.T) {
|
||||
_, err := buildAuthOptions(ClientConfig{
|
||||
AuthURL: "https://keystone.example:5000/v3/",
|
||||
Method: AuthMethodToken,
|
||||
Token: "tok-123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing project ID")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user