Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions internal/provider/openconfig/user.go
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason we use a patch over an update here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update was failing on Juniper device, I think it was to avoid changing username <==> creating new user. I can try out with update and share the message if you want.

}

// 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"`
Comment thread
felix-kaestner marked this conversation as resolved.
}
136 changes: 136 additions & 0 deletions internal/provider/openconfig/user_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
})
}
}
100 changes: 100 additions & 0 deletions test/gnmi/testdata/openconfig/user.txtar
Original file line number Diff line number Diff line change
@@ -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": []
}
}
}
}
}
Loading