diff --git a/internal/provider/openconfig/user.go b/internal/provider/openconfig/user.go new file mode 100644 index 000000000..963a7f630 --- /dev/null +++ b/internal/provider/openconfig/user.go @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "errors" + "fmt" + + "github.com/go-crypt/crypt/algorithm/shacrypt" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var _ provider.UserProvider = (*Provider)(nil) + +func (p *Provider) EnsureUser(ctx context.Context, req *provider.EnsureUserRequest) error { + if len(req.Roles) > 1 { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.roles", + Description: "the OpenConfig user model supports only a single role", + }) + } + + hashedPassword, err := p.hashPassword(ctx, req.Username, req.Password) + if err != nil { + return fmt.Errorf("hashing password for user %q: %w", req.Username, err) + } + + u := &User{ + Username: req.Username, + Config: &UserConfig{ + Username: req.Username, + Role: req.Roles[0], + PasswordHashed: hashedPassword, + SSHKey: req.SSHKey, + }, + } + return p.client.Patch(ctx, u) +} + +// hashPassword hashes the plaintext password using SHA-512 crypt ($6$). +// If the device already stores a hash for this user, its salt is reused so +// that the resulting hash is identical on every reconcile when the password +// has not changed (idempotency). +func (p *Provider) hashPassword(ctx context.Context, username, password string) (string, error) { + current := &User{Username: username} + switch err := p.client.GetConfig(ctx, current); { + case err == nil && current.Config != nil: + currentDigest, err := shacrypt.Decode(current.Config.PasswordHashed) + if err != nil { + return "", err + } + if currentDigest.Match(password) { + return current.Config.PasswordHashed, nil + } + case err == nil, errors.Is(err, gnmiext.ErrNil), status.Code(err) == codes.NotFound: + // user does not exist yet or config is empty, generate a fresh hash + default: + return "", err + } + + hash, err := shacrypt.NewSHA512() + if err != nil { + return "", err + } + + digest, err := hash.Hash(password) + if err != nil { + return "", err + } + return digest.Encode(), nil +} + +func (p *Provider) DeleteUser(ctx context.Context, req *provider.DeleteUserRequest) error { + return p.client.Delete(ctx, &User{Username: req.Username}) +} + +// Compile-time assertion. +var _ gnmiext.DataElement = (*User)(nil) + +// User targets an OpenConfig user entry. +type User struct { + Username string `json:"-"` + Config *UserConfig `json:"config"` +} + +func (u *User) XPath() string { + return fmt.Sprintf("openconfig-system:system/aaa/authentication/users/user[username=%s]", u.Username) +} + +// UserConfig holds the user config container leaves. +// PasswordHashed uses the password-hashed field which is stored by the +// device, enabling idempotent reconciliation. +type UserConfig struct { + Username string `json:"username"` + Role string `json:"role,omitempty"` + PasswordHashed string `json:"password-hashed"` + SSHKey string `json:"ssh-key,omitempty"` +} diff --git a/internal/provider/openconfig/user_test.go b/internal/provider/openconfig/user_test.go new file mode 100644 index 000000000..14c58ef2e --- /dev/null +++ b/internal/provider/openconfig/user_test.go @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "errors" + "testing" + + "github.com/go-crypt/crypt/algorithm/shacrypt" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +func newProviderWithClient(client gnmiext.Client) *Provider { + return &Provider{client: client} +} + +func mustHashPassword(t *testing.T, password string) string { + t.Helper() + h, err := shacrypt.NewSHA512() + if err != nil { + t.Fatalf("shacrypt.NewSHA512: %v", err) + } + d, err := h.Hash(password) + if err != nil { + t.Fatalf("shacrypt hash: %v", err) + } + return d.Encode() +} + +func TestHashPassword(t *testing.T) { + const ( + username = "testuser" + password = "secret" + ) + + existingHash := mustHashPassword(t, password) + getConfigErr := errors.New("get config error") + + tests := []struct { + name string + getConfigFunc func(ctx context.Context, elements ...gnmiext.DataElement) error + wantHash string // if set, exact hash expected + wantMatch bool // if true, result must verify against password + wantErr bool + }{ + { + name: "no user in config (ErrNil) — fresh hash", + getConfigFunc: func(_ context.Context, _ ...gnmiext.DataElement) error { + return gnmiext.ErrNil + }, + wantMatch: true, + }, + { + name: "no user in config (NotFound) — fresh hash", + getConfigFunc: func(_ context.Context, _ ...gnmiext.DataElement) error { + return status.Error(codes.NotFound, "not found") + }, + wantMatch: true, + }, + { + name: "user exists with nil Config — fresh hash", + getConfigFunc: func(_ context.Context, elements ...gnmiext.DataElement) error { + // leave Config nil, return no error + return nil + }, + wantMatch: true, + }, + { + name: "password matches stored hash — returns existing hash unchanged", + getConfigFunc: func(_ context.Context, elements ...gnmiext.DataElement) error { + elements[0].(*User).Config = &UserConfig{PasswordHashed: existingHash} + return nil + }, + wantHash: existingHash, + }, + { + name: "password not matched — fresh hash generated", + getConfigFunc: func(_ context.Context, elements ...gnmiext.DataElement) error { + elements[0].(*User).Config = &UserConfig{PasswordHashed: mustHashPassword(t, "differentpassword")} + return nil + }, + wantMatch: true, + }, + { + name: "GetConfig returns error — propagated", + getConfigFunc: func(_ context.Context, _ ...gnmiext.DataElement) error { + return getConfigErr + }, + wantErr: true, + }, + { + name: "stored hash is invalid — decode error propagated", + getConfigFunc: func(_ context.Context, elements ...gnmiext.DataElement) error { + elements[0].(*User).Config = &UserConfig{PasswordHashed: "notahash"} + return nil + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newProviderWithClient(&gnmiext.ClientMock{ + GetConfigFunc: tt.getConfigFunc, + }) + + got, err := p.hashPassword(t.Context(), username, password) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantHash != "" && got != tt.wantHash { + t.Errorf("hashPassword() = %q, want %q", got, tt.wantHash) + } + if tt.wantMatch { + d, err := shacrypt.Decode(got) + if err != nil { + t.Fatalf("shacrypt.Decode(%q): %v", got, err) + } + if !d.Match(password) { + t.Errorf("hashPassword() produced hash that does not match password") + } + } + }) + } +} diff --git a/test/gnmi/testdata/openconfig/user.txtar b/test/gnmi/testdata/openconfig/user.txtar new file mode 100644 index 000000000..ee63ab09e --- /dev/null +++ b/test/gnmi/testdata/openconfig/user.txtar @@ -0,0 +1,100 @@ +# User with password, role and ssh-key +-- secrets/user-password -- +apiVersion: v1 +kind: Secret +metadata: + name: user-password + namespace: default +type: Opaque +stringData: + password: Test1234! + +-- secrets/user-ssh-key -- +apiVersion: v1 +kind: Secret +metadata: + name: user-ssh-key + namespace: default +type: Opaque +stringData: + ssh-publickey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAQQDSGgsAKZn/hxPMKyfwKboiOEeuL9bTqW79QfEQ8h0kpGhkFJJEWR1e3BvXpdT9KYQOaKQnNw32atULweSQQNGh6 IronCore Test" + +-- users/user -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: User +metadata: + name: user + namespace: default +spec: + deviceRef: + name: device + username: testplan + password: + secretKeyRef: + name: user-password + key: password + roles: + - name: superuser + sshPublicKey: + secretKeyRef: + name: user-ssh-key + key: ssh-publickey + +-- state/preload -- +{ + "openconfig-system:system": { + "aaa": { + "authentication": { + "users": { + "user": [ + { + "config": { + "password-hashed": "$6$rounds=500000$testsalt12345678$cA/6tQxdty8SGL9isyhA6zjpPa94WfLXhHQan/nZEM2e8y.QLOfX/W7/dQ2GAA0OhvImEVbdKT5Hm9npdKVoE1", + "role": "superuser", + "ssh-key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAQQDSGgsAKZn/hxPMKyfwKboiOEeuL9bTqW79QfEQ8h0kpGhkFJJEWR1e3BvXpdT9KYQOaKQnNw32atULweSQQNGh6 IronCore Test", + "username": "testplan" + }, + "username": "testplan" + } + ] + } + } + } + } +} + +-- state/expect -- +{ + "openconfig-system:system": { + "aaa": { + "authentication": { + "users": { + "user": [ + { + "config": { + "password-hashed": "$6$rounds=500000$testsalt12345678$cA/6tQxdty8SGL9isyhA6zjpPa94WfLXhHQan/nZEM2e8y.QLOfX/W7/dQ2GAA0OhvImEVbdKT5Hm9npdKVoE1", + "role": "superuser", + "ssh-key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAQQDSGgsAKZn/hxPMKyfwKboiOEeuL9bTqW79QfEQ8h0kpGhkFJJEWR1e3BvXpdT9KYQOaKQnNw32atULweSQQNGh6 IronCore Test", + "username": "testplan" + }, + "username": "testplan" + } + ] + } + } + } + } +} + +-- state/delete -- +{ + "openconfig-system:system": { + "aaa": { + "authentication": { + "users": { + "user": [] + } + } + } + } +}