fix auth model

This commit is contained in:
ayurishchev committed 2026-08-21 09:58:08 +03:00
1 parent f81285b151
commit 5e3800e9cb
10 files changed
+901 -70

No files matched your search

+47 -7
View File
@@ -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
View File
@@ -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)
}
+28 -11
View File
@@ -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)
}
+118
View File
@@ -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")
}
}