diff --git a/internal/provider/openconfig/user.go b/internal/provider/openconfig/user.go new file mode 100644 index 000000000..7349396e1 --- /dev/null +++ b/internal/provider/openconfig/user.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "encoding/json" + "fmt" + + "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", + }) + } + u := &User{ + Username: req.Username, + Config: &UserConfig{ + Username: req.Username, + Role: req.Roles[0], + Password: req.Password, + SSHKey: req.SSHKey, + }, + } + return p.client.Patch(ctx, u) +} + +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,omitempty"` +} + +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. +// Password is write-only — the device returns a hashed value that would never match +// the plaintext, so we exclude it from unmarshal to avoid perpetual diffs. +type UserConfig struct { + Username string `json:"username"` + Role string `json:"role,omitempty"` + Password string `json:"password,omitempty"` + SSHKey string `json:"ssh-key,omitempty"` +} + +func (c *UserConfig) UnmarshalJSON(data []byte) error { + type alias struct { + Username string `json:"username"` + Role string `json:"role,omitempty"` + SSHKey string `json:"ssh-key,omitempty"` + } + var a alias + if err := json.Unmarshal(data, &a); err != nil { + return err + } + c.Username = a.Username + c.Role = a.Role + c.SSHKey = a.SSHKey + return nil +} diff --git a/test/gnmi/testdata/openconfig/user.txtar b/test/gnmi/testdata/openconfig/user.txtar new file mode 100644 index 000000000..e2f9fa555 --- /dev/null +++ b/test/gnmi/testdata/openconfig/user.txtar @@ -0,0 +1,69 @@ +# 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 -- +{} + +-- state/expect -- +{ + "openconfig-system:system": { + "aaa": { + "authentication": { + "users": { + "user": [ + { + "config": { + "role": "superuser", + "ssh-key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAQQDSGgsAKZn/hxPMKyfwKboiOEeuL9bTqW79QfEQ8h0kpGhkFJJEWR1e3BvXpdT9KYQOaKQnNw32atULweSQQNGh6 IronCore Test", + "username": "testplan" + }, + "username": "testplan" + } + ] + } + } + } + } +} + +-- state/delete -- +{}