├── hack └── boilerplate.go.txt ├── internal ├── controller │ ├── constants.go │ ├── request.go │ ├── suite_test.go │ ├── maskinportenclient_controller_test.go │ └── maskinportenclient_controller.go ├── assert │ └── assert.go ├── runtime │ └── runtime.go ├── config │ ├── util.go │ ├── config_test.go │ ├── koanf.go │ ├── config.go │ └── azure_keyvault.go ├── crypto │ ├── crypto_benchmark_test.go │ ├── jwks.go │ ├── jwt.go │ ├── crypto_test.go │ └── crypto.go ├── caching │ └── cached_atom.go ├── operatorcontext │ ├── operatorcontext_test.go │ └── operatorcontext.go ├── fakes │ ├── state.go │ └── db.go ├── internal.go ├── telemetry │ └── telemetry.go └── maskinporten │ ├── client_response.go │ ├── client_request.go │ └── http_api_client_test.go ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── config ├── samples │ ├── kustomization.yaml │ └── resources_v1alpha1_maskinportenclient.yaml ├── manager │ ├── kustomization.yaml │ └── manager.yaml ├── rbac │ ├── service_account.yaml │ ├── role_binding.yaml │ ├── leader_election_role_binding.yaml │ ├── maskinportenclient_viewer_role.yaml │ ├── role.yaml │ ├── maskinportenclient_editor_role.yaml │ ├── leader_election_role.yaml │ └── kustomization.yaml ├── crd │ ├── kustomizeconfig.yaml │ ├── kustomization.yaml │ └── bases │ │ └── resources.altinn.studio_maskinportenclients.yaml └── default │ └── kustomization.yaml ├── test ├── app │ ├── App │ │ ├── appsettings.Development.json │ │ ├── Program.cs │ │ ├── appsettings.json │ │ ├── Properties │ │ │ └── launchSettings.json │ │ ├── App.csproj │ │ └── Worker.cs │ ├── deployment │ │ ├── requirements.lock │ │ ├── Chart.yaml │ │ ├── .helmignore │ │ └── values.yaml │ ├── README.md │ ├── kind.config.yaml │ ├── Dockerfile │ └── Makefile ├── e2e │ ├── e2e_suite_test.go │ └── e2e_test.go └── utils │ └── utils.go ├── .dockerignore ├── README.md ├── .gitattributes ├── .editorconfig ├── docker-compose.yml ├── PROJECT ├── .gitignore ├── .github └── workflows │ └── build.yml ├── api └── v1alpha1 │ ├── groupversion_info.go │ ├── maskinportenclient_types.go │ └── zz_generated.deepcopy.go ├── .golangci.yml ├── Dockerfile ├── Dockerfile.fakes ├── LICENSE ├── altinn-k8s-operator.sln ├── CLAUDE.md ├── CONTRIBUTING.md ├── local.env ├── cmd ├── main.go └── utils │ └── main.go ├── go.mod └── Makefile /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/controller/constants.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | const UnkownStr = "Unknown" 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "editorconfig.editorconfig", 4 | "streetsidesoftware.code-spell-checker", 5 | "golang.go" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /config/samples/kustomization.yaml: -------------------------------------------------------------------------------- 1 | ## Append samples of your project ## 2 | resources: 3 | - resources_v1alpha1_maskinportenclient.yaml 4 | #+kubebuilder:scaffold:manifestskustomizesamples 5 | -------------------------------------------------------------------------------- /test/app/App/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.Hosting.Lifetime": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore build and test binaries. 3 | bin/ 4 | test/ 5 | .github/ 6 | .vscode/ 7 | 8 | *.svg 9 | *.out 10 | -------------------------------------------------------------------------------- /config/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manager.yaml 3 | apiVersion: kustomize.config.k8s.io/v1beta1 4 | kind: Kustomization 5 | images: 6 | - name: controller 7 | newName: example.com/altinn-k8s-operator 8 | newTag: v0.0.1 9 | -------------------------------------------------------------------------------- /config/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: altinn-k8s-operator 6 | app.kubernetes.io/managed-by: kustomize 7 | name: controller-manager 8 | namespace: system 9 | -------------------------------------------------------------------------------- /test/app/App/Program.cs: -------------------------------------------------------------------------------- 1 | using App; 2 | 3 | var builder = WebApplication.CreateBuilder(args); 4 | 5 | builder.Services.AddHostedService(); 6 | 7 | var app = builder.Build(); 8 | 9 | app.MapGet("/health", () => TypedResults.Ok()); 10 | 11 | app.Run(); 12 | -------------------------------------------------------------------------------- /config/samples/resources_v1alpha1_maskinportenclient.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: resources.altinn.studio/v1alpha1 2 | kind: MaskinportenClient 3 | metadata: 4 | labels: 5 | app: local-testapp-deployment 6 | name: local-testapp 7 | spec: 8 | scopes: ['altinn:resourceregistry/resource.read'] 9 | -------------------------------------------------------------------------------- /test/app/deployment/requirements.lock: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - name: deployment 3 | repository: file://../altinn-studio-charts/charts/deployment 4 | version: 3.0.1 5 | digest: sha256:5cc5cc0b50be528bf031face69c7f6d8548377a97282b92a8d40fb8f18a1e89f 6 | generated: "2024-06-11T15:39:37.46041011+02:00" 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # altinn-k8s-operator 2 | 3 | Home of Kubernetes operator(s) for Altinn 3. 4 | Operators are implemented using Go and Kubebuilder. 5 | 6 | ## Contributing 7 | 8 | See [/CONTRIBUTING.md](/CONTRIBUTING.md). 9 | 10 | ## Architecture 11 | 12 | [Architecture diagram](/docs/maskinporten.drawio.svg) 13 | -------------------------------------------------------------------------------- /test/app/App/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Kestrel": { 3 | "EndPoints": { 4 | "Http": { 5 | "Url": "http://*:5005" 6 | } 7 | } 8 | }, 9 | "Logging": { 10 | "LogLevel": { 11 | "Default": "Information", 12 | "Microsoft.Hosting.Lifetime": "Information" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/app/deployment/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | description: A Helm chart for Kubernetes 3 | # name can only be lowercase. It is used in the templates. 4 | name: deployment 5 | version: 1.1.0 6 | 7 | dependencies: 8 | - name: deployment 9 | repository: file://../altinn-studio-charts/charts/deployment 10 | version: 3.0.1 11 | -------------------------------------------------------------------------------- /test/app/App/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "App": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "environmentVariables": { 8 | "DOTNET_ENVIRONMENT": "Development" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/app/App/App.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/e2e/e2e_suite_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | ) 10 | 11 | // Run e2e tests using the Ginkgo runner. 12 | func TestE2E(t *testing.T) { 13 | RegisterFailHandler(Fail) 14 | fmt.Fprintf(GinkgoWriter, "Starting altinn-k8s-operator suite\n") 15 | RunSpecs(t, "e2e suite") 16 | } 17 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Treat all files in the Go repo as binary, with no git magic updating 2 | # line endings. This produces predictable results in different environments. 3 | 4 | # Editors should use .editorconfig to pick appropriate line endings. 5 | 6 | # Based on: https://github.com/golang/go/blob/beaf7f3282c2548267d3c894417cc4ecacc5d575/.gitattributes 7 | 8 | * -text 9 | go.sum merge=union 10 | -------------------------------------------------------------------------------- /internal/assert/assert.go: -------------------------------------------------------------------------------- 1 | package assert 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/go-errors/errors" 8 | ) 9 | 10 | func Assert(ok bool) { 11 | if !ok { 12 | log.Fatalln(errors.New("assertion failed")) 13 | } 14 | } 15 | 16 | func AssertWith(ok bool, format string, a ...any) { 17 | if !ok { 18 | log.Fatalln(errors.Errorf("assertion failed: %s", fmt.Sprintf(format, a...))) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/app/deployment/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | -------------------------------------------------------------------------------- /config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: altinn-k8s-operator 6 | app.kubernetes.io/managed-by: kustomize 7 | name: manager-rolebinding 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: ClusterRole 11 | name: manager-role 12 | subjects: 13 | - kind: ServiceAccount 14 | name: controller-manager 15 | namespace: system 16 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: altinn-k8s-operator 6 | app.kubernetes.io/managed-by: kustomize 7 | name: leader-election-rolebinding 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: Role 11 | name: leader-election-role 12 | subjects: 13 | - kind: ServiceAccount 14 | name: controller-manager 15 | namespace: system 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | insert_final_newline = true 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | end_of_line = lf 12 | 13 | [{Makefile,go.mod,go.sum,*.go,.gitmodules}] 14 | indent_style = tab 15 | indent_size = 4 16 | 17 | [*.md] 18 | indent_size = 4 19 | trim_trailing_whitespace = false 20 | 21 | eclint_indent_style = unset 22 | 23 | [Dockerfile] 24 | indent_size = 4 25 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | lgtm: 3 | image: grafana/otel-lgtm:0.7.6 4 | container_name: altinn-k8s-operator-grafana-lgtm 5 | ports: 6 | - "3000:3000" # Grafana 7 | - "4317:4317" # OTLP gRPC receiver 8 | - "4318:4318" # OTLP HTTP receiver 9 | 10 | maskinporten_fakes: 11 | container_name: altinn-k8s-operator-fakes 12 | build: 13 | dockerfile: Dockerfile.fakes 14 | ports: 15 | - "8050:8050" # Maskinporten API 16 | - "8051:8051" # Maskinporten self service API 17 | -------------------------------------------------------------------------------- /config/crd/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This file is for teaching kustomize how to substitute name and namespace reference in CRD 2 | nameReference: 3 | - kind: Service 4 | version: v1 5 | fieldSpecs: 6 | - kind: CustomResourceDefinition 7 | version: v1 8 | group: apiextensions.k8s.io 9 | path: spec/conversion/webhook/clientConfig/service/name 10 | 11 | namespace: 12 | - kind: CustomResourceDefinition 13 | version: v1 14 | group: apiextensions.k8s.io 15 | path: spec/conversion/webhook/clientConfig/service/namespace 16 | create: false 17 | 18 | varReference: 19 | - path: metadata/annotations 20 | -------------------------------------------------------------------------------- /test/app/README.md: -------------------------------------------------------------------------------- 1 | ## Test project to use with local cluster 2 | 3 | ```sh 4 | # Gets a local copy of altinn-studio-charts 5 | make download-charts 6 | 7 | # Creates cluster, installs CRDs 8 | make create 9 | # Installs test application (not the operator) 10 | make deploy 11 | 12 | # Run operator, e.g. using the launch profile in VSCode or using `make run` in the root folder 13 | 14 | # Creates a CRD instance based on the sample in maskinporten-poc/kubebuilder/config/samples/resources_v1alpha1_maskinportenclient.yaml 15 | make client 16 | 17 | # Do some testing 18 | 19 | make destroy 20 | # Deletes the cluster 21 | ``` 22 | -------------------------------------------------------------------------------- /config/rbac/maskinportenclient_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view maskinportenclients. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: altinn-k8s-operator 7 | app.kubernetes.io/managed-by: kustomize 8 | name: maskinportenclient-viewer-role 9 | rules: 10 | - apiGroups: 11 | - resources.altinn.studio 12 | resources: 13 | - maskinportenclients 14 | verbs: 15 | - get 16 | - list 17 | - watch 18 | - apiGroups: 19 | - resources.altinn.studio 20 | resources: 21 | - maskinportenclients/status 22 | verbs: 23 | - get 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.formatTool": "custom", 3 | "go.alternateTools": { 4 | "customFormatter": "golines", 5 | "golangci-lint": "${workspaceFolder}/bin/golangci-lint-v1.57.2" 6 | }, 7 | "go.formatFlags": [ 8 | "-m", 9 | "120", 10 | "-w", 11 | "--ignore-generated" 12 | ], 13 | "go.lintTool": "golangci-lint", 14 | "go.lintFlags": [ 15 | "--issues-exit-code=0" 16 | ], 17 | "cSpell.words": [ 18 | "golines", 19 | "segmentio", 20 | "azcore", 21 | "azidentity", 22 | "azsecrets", 23 | "keyvault" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /config/rbac/role.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: manager-role 6 | rules: 7 | - apiGroups: 8 | - resources.altinn.studio 9 | resources: 10 | - maskinportenclients 11 | verbs: 12 | - create 13 | - delete 14 | - get 15 | - list 16 | - patch 17 | - update 18 | - watch 19 | - apiGroups: 20 | - resources.altinn.studio 21 | resources: 22 | - maskinportenclients/finalizers 23 | verbs: 24 | - update 25 | - apiGroups: 26 | - resources.altinn.studio 27 | resources: 28 | - maskinportenclients/status 29 | verbs: 30 | - get 31 | - patch 32 | - update 33 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | # Code generated by tool. DO NOT EDIT. 2 | # This file is used to track the info used to scaffold your project 3 | # and allow the plugins properly work. 4 | # More info: https://book.kubebuilder.io/reference/project-config.html 5 | domain: altinn.studio 6 | layout: 7 | - go.kubebuilder.io/v4 8 | projectName: altinn-k8s-operator 9 | repo: github.com/altinn/altinn-k8s-operator 10 | resources: 11 | - api: 12 | crdVersion: v1 13 | namespaced: true 14 | controller: true 15 | domain: altinn.studio 16 | group: resources 17 | kind: MaskinportenClient 18 | path: github.com/altinn/altinn-k8s-operator/api/v1alpha1 19 | version: v1alpha1 20 | version: "3" 21 | -------------------------------------------------------------------------------- /config/rbac/maskinportenclient_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit maskinportenclients. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: altinn-k8s-operator 7 | app.kubernetes.io/managed-by: kustomize 8 | name: maskinportenclient-editor-role 9 | rules: 10 | - apiGroups: 11 | - resources.altinn.studio 12 | resources: 13 | - maskinportenclients 14 | verbs: 15 | - create 16 | - delete 17 | - get 18 | - list 19 | - patch 20 | - update 21 | - watch 22 | - apiGroups: 23 | - resources.altinn.studio 24 | resources: 25 | - maskinportenclients/status 26 | verbs: 27 | - get 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | bin/* 8 | Dockerfile.cross 9 | .gocache/ 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Go workspace file 18 | go.work 19 | 20 | # Kubernetes Generated files - skip generated files, except for vendored files 21 | !vendor/**/zz_generated.* 22 | 23 | # editor and IDE paraphernalia 24 | .idea 25 | *.swp 26 | *.swo 27 | *~ 28 | 29 | # .NET 30 | test/**/obj/ 31 | test/**/bin/ 32 | test/**/charts/ 33 | 34 | *.env 35 | !local.env.sample 36 | 37 | # Downloaded charts 38 | test/app/altinn-studio-charts/ 39 | -------------------------------------------------------------------------------- /test/app/App/Worker.cs: -------------------------------------------------------------------------------- 1 | namespace App; 2 | 3 | public class Worker : BackgroundService 4 | { 5 | private readonly ILogger _logger; 6 | 7 | public Worker(ILogger logger) 8 | { 9 | _logger = logger; 10 | } 11 | 12 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 13 | { 14 | while (!stoppingToken.IsCancellationRequested) 15 | { 16 | if (_logger.IsEnabled(LogLevel.Information)) 17 | { 18 | _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now); 19 | } 20 | await Task.Delay(1000, stoppingToken); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/app/kind.config.yaml: -------------------------------------------------------------------------------- 1 | kind: Cluster 2 | apiVersion: kind.x-k8s.io/v1alpha4 3 | name: operator 4 | nodes: 5 | - role: control-plane 6 | image: kindest/node:v1.30.0@sha256:51a1434a5397193442f0be2a297b488b6c919ce8a3931be0ce822606ea5ca245 7 | kubeadmConfigPatches: 8 | - | 9 | kind: InitConfiguration 10 | nodeRegistration: 11 | kubeletExtraArgs: 12 | node-labels: "ingress-ready=true" 13 | extraPortMappings: 14 | - containerPort: 30000 15 | hostPort: 80 16 | - containerPort: 30001 17 | hostPort: 443 18 | - containerPort: 30002 19 | hostPort: 8020 20 | - role: worker 21 | image: kindest/node:v1.30.0@sha256:51a1434a5397193442f0be2a297b488b6c919ce8a3931be0ce822606ea5ca245 22 | -------------------------------------------------------------------------------- /internal/runtime/runtime.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "github.com/altinn/altinn-k8s-operator/internal/config" 5 | "github.com/altinn/altinn-k8s-operator/internal/crypto" 6 | "github.com/altinn/altinn-k8s-operator/internal/maskinporten" 7 | "github.com/altinn/altinn-k8s-operator/internal/operatorcontext" 8 | "github.com/jonboulle/clockwork" 9 | "go.opentelemetry.io/otel/metric" 10 | "go.opentelemetry.io/otel/trace" 11 | ) 12 | 13 | type Runtime interface { 14 | GetConfig() *config.Config 15 | GetOperatorContext() *operatorcontext.Context 16 | GetCrypto() *crypto.CryptoService 17 | GetMaskinportenApiClient() *maskinporten.HttpApiClient 18 | GetClock() clockwork.Clock 19 | Tracer() trace.Tracer 20 | Meter() metric.Meter 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: Run on Ubuntu 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Clone the code 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: '~1.22' 21 | 22 | - name: Setup golines 23 | run: go install github.com/segmentio/golines@latest 24 | 25 | - name: go mod tidy 26 | run: go mod tidy 27 | 28 | - name: Build 29 | run: make 30 | 31 | - name: Lint 32 | run: make lint 33 | 34 | - name: Test 35 | run: make test 36 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions to do leader election. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: Role 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: altinn-k8s-operator 7 | app.kubernetes.io/managed-by: kustomize 8 | name: leader-election-role 9 | rules: 10 | - apiGroups: 11 | - "" 12 | resources: 13 | - configmaps 14 | verbs: 15 | - get 16 | - list 17 | - watch 18 | - create 19 | - update 20 | - patch 21 | - delete 22 | - apiGroups: 23 | - coordination.k8s.io 24 | resources: 25 | - leases 26 | verbs: 27 | - get 28 | - list 29 | - watch 30 | - create 31 | - update 32 | - patch 33 | - delete 34 | - apiGroups: 35 | - "" 36 | resources: 37 | - events 38 | verbs: 39 | - create 40 | - patch 41 | -------------------------------------------------------------------------------- /api/v1alpha1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | // Package v1alpha1 contains API Schema definitions for the resources v1alpha1 API group 2 | // +kubebuilder:object:generate=true 3 | // +groupName=resources.altinn.studio 4 | package v1alpha1 5 | 6 | import ( 7 | "k8s.io/apimachinery/pkg/runtime/schema" 8 | "sigs.k8s.io/controller-runtime/pkg/scheme" 9 | ) 10 | 11 | var ( 12 | // GroupVersion is group version used to register these objects 13 | GroupVersion = schema.GroupVersion{Group: "resources.altinn.studio", Version: "v1alpha1"} 14 | 15 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 16 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 17 | 18 | // AddToScheme adds the types in this group-version to the given scheme. 19 | AddToScheme = SchemeBuilder.AddToScheme 20 | ) 21 | -------------------------------------------------------------------------------- /test/app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build 2 | WORKDIR /App 3 | 4 | COPY /App/App.csproj . 5 | RUN dotnet restore App.csproj 6 | 7 | COPY /App . 8 | 9 | RUN dotnet publish App.csproj --configuration Release --output /app_output 10 | 11 | FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS final 12 | EXPOSE 5005 13 | WORKDIR /App 14 | COPY --from=build /app_output . 15 | ENV ASPNETCORE_URLS= 16 | 17 | # setup the user and group 18 | # busybox doesn't include longopts, so the options are roughly 19 | # -g --gid 20 | # -u --uid 21 | # -G --group 22 | # -D --disable-password 23 | # -s --shell 24 | RUN addgroup -g 3000 dotnet && adduser -u 1000 -G dotnet -D -s /bin/false dotnet 25 | 26 | USER dotnet 27 | RUN mkdir /tmp/logtelemetry 28 | 29 | ENTRYPOINT ["dotnet", "App.dll"] 30 | -------------------------------------------------------------------------------- /internal/config/util.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | func TryFindProjectRoot() string { 10 | for { 11 | if fileExists("./go.mod") || fileExists("./*.env") { 12 | currentDir, err := os.Getwd() 13 | if err != nil { 14 | return "" 15 | } 16 | return currentDir 17 | } 18 | 19 | if err := os.Chdir(".."); err != nil { 20 | return "" 21 | } 22 | } 23 | } 24 | 25 | func fileExists(pattern string) bool { 26 | matches, err := filepath.Glob(pattern) 27 | if err != nil { 28 | return false 29 | } 30 | 31 | return len(matches) > 0 32 | } 33 | 34 | func GetConfigFilePathForEnv(env string) string { 35 | rootDir := TryFindProjectRoot() 36 | if rootDir == "" { 37 | return "" 38 | } 39 | 40 | return filepath.Join(rootDir, fmt.Sprintf("%s.env", env)) 41 | } 42 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | # All RBAC will be applied under this service account in 3 | # the deployment namespace. You may comment out this resource 4 | # if your manager will use a service account that exists at 5 | # runtime. Be sure to update RoleBinding and ClusterRoleBinding 6 | # subjects if changing service account names. 7 | - service_account.yaml 8 | - role.yaml 9 | - role_binding.yaml 10 | - leader_election_role.yaml 11 | - leader_election_role_binding.yaml 12 | # For each CRD, "Editor" and "Viewer" roles are scaffolded by 13 | # default, aiding admins in cluster management. Those roles are 14 | # not used by the Project itself. You can comment the following lines 15 | # if you do not want those helpers be installed with your Project. 16 | - maskinportenclient_editor_role.yaml 17 | - maskinportenclient_viewer_role.yaml 18 | 19 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | allow-parallel-runners: true 4 | 5 | issues: 6 | # don't skip warning about doc comments 7 | # don't exclude the default set of lint 8 | exclude-use-default: false 9 | # restore some of the defaults 10 | # (fill in the rest as needed) 11 | exclude-rules: 12 | - path: "api/*" 13 | linters: 14 | - lll 15 | - path: "internal/*" 16 | linters: 17 | - dupl 18 | - lll 19 | linters: 20 | disable-all: true 21 | enable: 22 | - dupl 23 | - errcheck 24 | - exportloopref 25 | - ginkgolinter 26 | - goconst 27 | - gocyclo 28 | - gofmt 29 | - goimports 30 | - gosimple 31 | - govet 32 | - ineffassign 33 | - lll 34 | - misspell 35 | - nakedret 36 | - prealloc 37 | - staticcheck 38 | - typecheck 39 | - unconvert 40 | - unparam 41 | - unused 42 | -------------------------------------------------------------------------------- /config/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # This kustomization.yaml is not intended to be run by itself, 2 | # since it depends on service name and namespace that are out of this kustomize package. 3 | # It should be run by config/default 4 | resources: 5 | - bases/resources.altinn.studio_maskinportenclients.yaml 6 | #+kubebuilder:scaffold:crdkustomizeresource 7 | 8 | patches: [] 9 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 10 | # patches here are for enabling the conversion webhook for each CRD 11 | #- path: patches/webhook_in_maskinportenclients.yaml 12 | #+kubebuilder:scaffold:crdkustomizewebhookpatch 13 | 14 | # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. 15 | # patches here are for enabling the CA injection for each CRD 16 | #- path: patches/cainjection_in_maskinportenclients.yaml 17 | #+kubebuilder:scaffold:crdkustomizecainjectionpatch 18 | 19 | # [WEBHOOK] To enable webhook, uncomment the following section 20 | # the following config is for teaching kustomize how to do kustomization for CRDs. 21 | 22 | #configurations: 23 | #- kustomizeconfig.yaml 24 | -------------------------------------------------------------------------------- /test/app/deployment/values.yaml: -------------------------------------------------------------------------------- 1 | # Additional configurations are available. See: https://docs.altinn.studio/app/development/configuration/deployment/ 2 | 3 | deployment: 4 | replicaCount: 1 5 | 6 | autoscaling: 7 | enabled: false 8 | 9 | image: 10 | pullPolicy: Never 11 | pullSecrets: [] 12 | 13 | ingressRoute: 14 | name: ingress-route 15 | entryPoints: 16 | - http 17 | - https 18 | routes: 19 | - match: Host(`localhost:30000`)&&PathPrefix(`/app`) 20 | kind: Rule 21 | services: 22 | - name: deployment 23 | port: 80 24 | middlewares: 25 | - name: hsts-header 26 | 27 | resources: 28 | requests: 29 | cpu: 50m 30 | memory: 128Mi 31 | 32 | volumeMounts: [] 33 | # - name: datakeys 34 | # mountPath: /mnt/keys 35 | # - name: accesstoken 36 | # mountPath: "/accesstoken" 37 | 38 | volumes: [] 39 | # - name : datakeys 40 | # persistentVolumeClaim: 41 | # claimName: keys 42 | # - name: accesstoken 43 | # secret: 44 | # secretName: accesstoken 45 | 46 | readiness: 47 | enabled: true 48 | 49 | liveness: 50 | enabled: true 51 | -------------------------------------------------------------------------------- /internal/crypto/crypto_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "testing" 7 | "time" 8 | 9 | "github.com/altinn/altinn-k8s-operator/internal/operatorcontext" 10 | "github.com/altinn/altinn-k8s-operator/test/utils" 11 | "github.com/jonboulle/clockwork" 12 | ) 13 | 14 | func benchmarkCreateJwks(b *testing.B, algo x509.SignatureAlgorithm, keySize int) { 15 | operatorCtx := operatorcontext.DiscoverOrDie(context.Background()) 16 | clock := clockwork.NewFakeClockAt(time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)) 17 | random := utils.NewDeterministicRand() 18 | service := NewService(operatorCtx, clock, random, algo, keySize) 19 | certCommonName := "benchmark" 20 | notAfter := clock.Now().UTC().Add(30 * 24 * time.Hour) 21 | 22 | b.ReportAllocs() 23 | b.ResetTimer() 24 | 25 | for i := 0; i < b.N; i++ { 26 | if _, err := service.CreateJwks(certCommonName, notAfter); err != nil { 27 | b.Fatalf("CreateJwks: %v", err) 28 | } 29 | } 30 | } 31 | 32 | func BenchmarkCreateJwks_SHA256RSA2048(b *testing.B) { 33 | benchmarkCreateJwks(b, x509.SHA256WithRSA, 2048) 34 | } 35 | 36 | func BenchmarkCreateJwks_SHA512RSA4096(b *testing.B) { 37 | benchmarkCreateJwks(b, x509.SHA512WithRSA, 4096) 38 | } 39 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | // go run ./cmd/main.go 8 | { 9 | "name": "Run operator", 10 | "type": "go", 11 | "request": "launch", 12 | "mode": "debug", 13 | "program": "${workspaceFolder}/cmd/main.go", 14 | "console": "integratedTerminal" 15 | }, 16 | // go run cmd/maskinporten_fakes/main.go 17 | { 18 | "name": "Run Fake APIs", 19 | "type": "go", 20 | "request": "launch", 21 | "mode": "debug", 22 | "program": "${workspaceFolder}/cmd/maskinporten_fakes/main.go", 23 | "console": "integratedTerminal" 24 | }, 25 | // go test ./test/e2e/ -v -ginkgo.v 26 | { 27 | "name": "Run e2e tests", 28 | "type": "go", 29 | "request": "launch", 30 | "mode": "test", 31 | "program": "${workspaceFolder}/test/e2e/", 32 | "console": "integratedTerminal" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.22 AS builder 3 | ARG TARGETOS 4 | ARG TARGETARCH 5 | 6 | WORKDIR /workspace 7 | # Copy the Go Modules manifests 8 | COPY go.mod go.mod 9 | COPY go.sum go.sum 10 | # cache deps before building and copying source so that we don't need to re-download as much 11 | # and so that source changes don't invalidate our downloaded layer 12 | RUN go mod download 13 | 14 | # Copy the go source 15 | COPY . ./ 16 | 17 | # Build 18 | # the GOARCH has not a default value to allow the binary be built according to the host where the command 19 | # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO 20 | # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, 21 | # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. 22 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go 23 | 24 | # Use distroless as minimal base image to package the manager binary 25 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 26 | FROM gcr.io/distroless/static:nonroot 27 | WORKDIR / 28 | COPY --from=builder /workspace/manager . 29 | COPY --from=builder /workspace/dev.env . 30 | COPY --from=builder /workspace/local.env . 31 | USER 65532:65532 32 | 33 | ENTRYPOINT ["/manager"] 34 | -------------------------------------------------------------------------------- /Dockerfile.fakes: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.22 AS builder 3 | ARG TARGETOS 4 | ARG TARGETARCH 5 | 6 | WORKDIR /workspace 7 | # Copy the Go Modules manifests 8 | COPY go.mod go.mod 9 | COPY go.sum go.sum 10 | # cache deps before building and copying source so that we don't need to re-download as much 11 | # and so that source changes don't invalidate our downloaded layer 12 | RUN go mod download 13 | 14 | # Copy the go source 15 | COPY . ./ 16 | 17 | # Build 18 | # the GOARCH has not a default value to allow the binary be built according to the host where the command 19 | # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO 20 | # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, 21 | # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. 22 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o maskinporten_fakes cmd/maskinporten_fakes/main.go 23 | 24 | # Use distroless as minimal base image to package the maskinporten_fakes binary 25 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 26 | FROM gcr.io/distroless/static:nonroot 27 | WORKDIR / 28 | COPY --from=builder /workspace/maskinporten_fakes . 29 | COPY --from=builder /workspace/local.env . 30 | USER 65532:65532 31 | 32 | ENTRYPOINT ["/maskinporten_fakes"] 33 | -------------------------------------------------------------------------------- /internal/caching/cached_atom.go: -------------------------------------------------------------------------------- 1 | package caching 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/jonboulle/clockwork" 9 | ) 10 | 11 | type CachedAtom[T any] struct { 12 | mutex sync.RWMutex 13 | clock clockwork.Clock 14 | retriever func(ctx context.Context) (*T, error) 15 | current *T 16 | currentFetchedAt time.Time 17 | expireAfter time.Duration 18 | } 19 | 20 | func NewCachedAtom[T any]( 21 | expireAfter time.Duration, 22 | clock clockwork.Clock, 23 | retriever func(ctx context.Context) (*T, error), 24 | ) CachedAtom[T] { 25 | return CachedAtom[T]{ 26 | mutex: sync.RWMutex{}, 27 | clock: clock, 28 | expireAfter: expireAfter, 29 | retriever: retriever, 30 | } 31 | } 32 | 33 | func (c *CachedAtom[T]) Get(ctx context.Context) (*T, error) { 34 | c.mutex.RLock() 35 | now := c.clock.Now() 36 | if c.currentFetchedAt.IsZero() || now.Sub(c.currentFetchedAt) > c.expireAfter { 37 | c.mutex.RUnlock() 38 | c.mutex.Lock() 39 | defer c.mutex.Unlock() 40 | 41 | now = c.clock.Now() 42 | if now.Sub(c.currentFetchedAt) <= c.expireAfter { 43 | return c.current, nil 44 | } 45 | 46 | value, err := c.retriever(ctx) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | c.current = value 52 | c.currentFetchedAt = now 53 | return c.current, nil 54 | } 55 | defer c.mutex.RUnlock() 56 | 57 | return c.current, nil 58 | } 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024, Altinn 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of Altinn nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /altinn-k8s-operator.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.5.002.0 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{63F1827E-11BB-4630-A5EB-601BCC81A78A}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "app", "app", "{E0839527-9F10-4B9A-83A3-DE10B4465C33}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "App", "test\app\App\App.csproj", "{EBC00E96-99DC-4A2A-A62A-2526B17CA066}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {EBC00E96-99DC-4A2A-A62A-2526B17CA066}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {EBC00E96-99DC-4A2A-A62A-2526B17CA066}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {EBC00E96-99DC-4A2A-A62A-2526B17CA066}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {EBC00E96-99DC-4A2A-A62A-2526B17CA066}.Release|Any CPU.Build.0 = Release|Any CPU 22 | EndGlobalSection 23 | GlobalSection(SolutionProperties) = preSolution 24 | HideSolutionNode = FALSE 25 | EndGlobalSection 26 | GlobalSection(NestedProjects) = preSolution 27 | {E0839527-9F10-4B9A-83A3-DE10B4465C33} = {63F1827E-11BB-4630-A5EB-601BCC81A78A} 28 | {EBC00E96-99DC-4A2A-A62A-2526B17CA066} = {E0839527-9F10-4B9A-83A3-DE10B4465C33} 29 | EndGlobalSection 30 | GlobalSection(ExtensibilityGlobals) = postSolution 31 | SolutionGuid = {0FE2625E-57E2-4264-849F-0B7995E22F21} 32 | EndGlobalSection 33 | EndGlobal 34 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/altinn/altinn-k8s-operator/internal/operatorcontext" 10 | "github.com/go-playground/validator/v10" 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | func TestConfigMissingValuesFail(t *testing.T) { 15 | RegisterTestingT(t) 16 | 17 | file, err := os.CreateTemp(os.TempDir(), "*.env") 18 | Expect(err).NotTo(HaveOccurred()) 19 | defer func() { 20 | err := file.Close() 21 | Expect(err).NotTo(HaveOccurred()) 22 | }() 23 | defer func() { 24 | err := os.Remove(file.Name()) 25 | Expect(err).NotTo(HaveOccurred()) 26 | }() 27 | 28 | _, err = file.WriteString("maskinporten_api.url=https://example.com") 29 | Expect(err).NotTo(HaveOccurred()) 30 | 31 | operatorContext := operatorcontext.DiscoverOrDie(context.Background()) 32 | cfg, err := GetConfig(operatorContext, ConfigSourceDefault, file.Name()) 33 | Expect(cfg).To(BeNil()) 34 | Expect(err).To(HaveOccurred()) 35 | _, ok := err.(validator.ValidationErrors) 36 | errType := reflect.TypeOf(err) 37 | Expect(errType.String()).To(Equal("validator.ValidationErrors")) 38 | Expect(ok).To(BeTrue()) 39 | } 40 | 41 | func TestConfigTestEnvLoadsOk(t *testing.T) { 42 | RegisterTestingT(t) 43 | 44 | operatorContext := operatorcontext.DiscoverOrDie(context.Background()) 45 | cfg, err := GetConfig(operatorContext, ConfigSourceDefault, "") 46 | Expect(err).NotTo(HaveOccurred()) 47 | Expect(cfg).NotTo(BeNil()) 48 | Expect(cfg.MaskinportenApi.ClientId).To(Equal("altinn_apps_supplier_client")) 49 | Expect(cfg.MaskinportenApi.AuthorityUrl).To(Equal("http://localhost:8050")) 50 | Expect(cfg.MaskinportenApi.Jwk).NotTo(BeNil()) 51 | } 52 | -------------------------------------------------------------------------------- /internal/config/koanf.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | 8 | "github.com/altinn/altinn-k8s-operator/internal/operatorcontext" 9 | "github.com/knadh/koanf/parsers/dotenv" 10 | "github.com/knadh/koanf/providers/file" 11 | "github.com/knadh/koanf/v2" 12 | ) 13 | 14 | var ( 15 | k = koanf.New(".") 16 | parser = dotenv.ParserEnv("", ".", func(s string) string { return s }) 17 | ) 18 | 19 | func loadFromKoanf(operatorContext *operatorcontext.Context, configFilePath string) (*Config, error) { 20 | span := operatorContext.StartSpan("GetConfig.Koanf") 21 | defer span.End() 22 | 23 | rootDir := TryFindProjectRoot() 24 | 25 | if configFilePath == "" { 26 | configFilePath = fmt.Sprintf("%s.env", operatorContext.Environment) 27 | } 28 | 29 | if !operatorContext.IsLocal() && !operatorContext.IsDev() { 30 | return nil, fmt.Errorf("loading config from koanf is only supported for local environment") 31 | } 32 | 33 | if _, err := os.Stat(configFilePath); os.IsNotExist(err) { 34 | if path.IsAbs(configFilePath) { 35 | return nil, fmt.Errorf("env file does not exist: '%s'", configFilePath) 36 | } else { 37 | return nil, fmt.Errorf("env file does not exist in '%s': '%s'", rootDir, configFilePath) 38 | } 39 | } 40 | 41 | if !path.IsAbs(configFilePath) { 42 | configFilePath = path.Join(rootDir, configFilePath) 43 | } 44 | 45 | if err := k.Load(file.Provider(configFilePath), parser); err != nil { 46 | return nil, fmt.Errorf("error loading config '%s': %w", configFilePath, err) 47 | } 48 | 49 | var cfg Config 50 | 51 | if err := k.Unmarshal("", &cfg); err != nil { 52 | return nil, fmt.Errorf("error unmarshalling config '%s': %w", configFilePath, err) 53 | } 54 | 55 | return &cfg, nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/operatorcontext/operatorcontext_test.go: -------------------------------------------------------------------------------- 1 | package operatorcontext 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | . "github.com/onsi/gomega" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | func TestDiscoversOk(t *testing.T) { 12 | RegisterTestingT(t) 13 | 14 | operatorContext, err := Discover(context.Background()) 15 | Expect(err).NotTo(HaveOccurred()) 16 | Expect(operatorContext).NotTo(BeNil()) 17 | } 18 | 19 | func TestCancellationBefore(t *testing.T) { 20 | RegisterTestingT(t) 21 | 22 | ctx, cancel := context.WithCancel(context.Background()) 23 | cancel() 24 | operatorContext, err := Discover(ctx) 25 | Expect(operatorContext).To(BeNil()) 26 | Expect(ctx.Err()).To(MatchError(context.Canceled)) 27 | Expect(err).To(MatchError(context.Canceled)) 28 | 29 | } 30 | 31 | func TestCancellationAfter(t *testing.T) { 32 | RegisterTestingT(t) 33 | 34 | ctx, cancel := context.WithCancel(context.Background()) 35 | operatorContext, err := Discover(ctx) 36 | Expect(err).NotTo(HaveOccurred()) 37 | Expect(operatorContext).NotTo(BeNil()) 38 | Expect(ctx.Err()).To(Succeed()) 39 | 40 | cancel() 41 | Expect(ctx.Err()).To(MatchError(context.Canceled)) 42 | } 43 | 44 | func TestSpanStart(t *testing.T) { 45 | RegisterTestingT(t) 46 | 47 | originalContext := context.Background() 48 | operatorContext := DiscoverOrDie(originalContext) 49 | originalSpan := trace.SpanFromContext(operatorContext.Context) 50 | Expect(operatorContext.Context).To(Equal(originalContext)) 51 | 52 | span := operatorContext.StartSpan("Test") 53 | defer span.End() 54 | Expect(span).ToNot(Equal(originalSpan)) 55 | Expect(operatorContext.Context).ToNot(Equal(originalContext)) 56 | spanFromContext := trace.SpanFromContext(operatorContext.Context) 57 | Expect(spanFromContext).To(Equal(span)) 58 | } 59 | -------------------------------------------------------------------------------- /internal/controller/request.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | resourcesv1alpha1 "github.com/altinn/altinn-k8s-operator/api/v1alpha1" 9 | "k8s.io/apimachinery/pkg/types" 10 | ctrl "sigs.k8s.io/controller-runtime" 11 | ) 12 | 13 | type requestKind int 14 | 15 | const ( 16 | RequestCreateKind requestKind = iota + 1 17 | RequestUpdateKind 18 | RequestDeleteKind 19 | ) 20 | 21 | var requestKindToString = map[requestKind]string{ 22 | RequestCreateKind: "Create", 23 | RequestUpdateKind: "Update", 24 | RequestDeleteKind: "Delete", 25 | } 26 | 27 | func (k requestKind) String() string { 28 | if s, ok := requestKindToString[k]; ok { 29 | return s 30 | } 31 | return UnkownStr 32 | } 33 | 34 | type maskinportenClientRequest struct { 35 | NamespacedName types.NamespacedName 36 | Name string 37 | Namespace string 38 | AppId string 39 | AppLabel string 40 | Kind requestKind 41 | Instance *resourcesv1alpha1.MaskinportenClient 42 | } 43 | 44 | func (r *MaskinportenClientReconciler) mapRequest( 45 | ctx context.Context, 46 | req ctrl.Request, 47 | ) (*maskinportenClientRequest, error) { 48 | _, span := r.runtime.Tracer().Start(ctx, "Reconcile.mapRequest") 49 | defer span.End() 50 | 51 | nameSplit := strings.Split(req.Name, "-") 52 | if len(nameSplit) < 2 { 53 | return nil, fmt.Errorf("unexpected name format for MaskinportenClient resource: %s", req.Name) 54 | } 55 | appId := nameSplit[1] 56 | 57 | operatorContext := r.runtime.GetOperatorContext() 58 | 59 | return &maskinportenClientRequest{ 60 | NamespacedName: req.NamespacedName, 61 | Name: req.Name, 62 | Namespace: req.Namespace, 63 | AppId: appId, 64 | AppLabel: fmt.Sprintf("%s-%s-deployment", operatorContext.ServiceOwnerName, appId), 65 | }, nil 66 | } 67 | -------------------------------------------------------------------------------- /internal/operatorcontext/operatorcontext.go: -------------------------------------------------------------------------------- 1 | package operatorcontext 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/altinn/altinn-k8s-operator/internal/telemetry" 7 | "github.com/google/uuid" 8 | "go.opentelemetry.io/otel" 9 | "go.opentelemetry.io/otel/trace" 10 | ) 11 | 12 | const EnvironmentLocal = "local" 13 | const EnvironmentDev = "dev" 14 | 15 | type Context struct { 16 | ServiceOwnerName string 17 | ServiceOwnerOrgNo string 18 | Environment string 19 | RunId string 20 | // Context which will be cancelled when the program is shut down 21 | Context context.Context 22 | tracer trace.Tracer 23 | } 24 | 25 | func (c *Context) IsLocal() bool { 26 | return c.Environment == EnvironmentLocal 27 | } 28 | 29 | func (c *Context) IsDev() bool { 30 | return c.Environment == EnvironmentDev 31 | } 32 | 33 | func (c *Context) OverrideEnvironment(env string) { 34 | c.Environment = env 35 | } 36 | 37 | func Discover(ctx context.Context) (*Context, error) { 38 | err := ctx.Err() 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | // TODO: this should come from the environment/context somewhere 44 | // there should be 1:1 mapping between TE/env:cluster 45 | serviceOwnerName := "local" 46 | serviceOwnerOrgNo := "991825827" 47 | environment := EnvironmentLocal 48 | runId, err := uuid.NewRandom() 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return &Context{ 54 | ServiceOwnerName: serviceOwnerName, 55 | ServiceOwnerOrgNo: serviceOwnerOrgNo, 56 | Environment: environment, 57 | RunId: runId.String(), 58 | Context: ctx, 59 | tracer: otel.Tracer(telemetry.ServiceName), 60 | }, nil 61 | } 62 | 63 | func DiscoverOrDie(ctx context.Context) *Context { 64 | context, err := Discover(ctx) 65 | if err != nil { 66 | panic(err) 67 | } 68 | return context 69 | } 70 | 71 | func (c *Context) StartSpan( 72 | spanName string, 73 | opts ...trace.SpanStartOption, 74 | ) trace.Span { 75 | ctx, span := c.tracer.Start(c.Context, spanName, opts...) 76 | c.Context = ctx 77 | return span 78 | } 79 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is a Kubernetes operator for Altinn 3, built with Go and Kubebuilder. The primary focus is managing Maskinporten clients through custom resources. 8 | 9 | ## Development Commands 10 | 11 | ### Building and Testing 12 | ```bash 13 | make # Build the project 14 | make test # Run unit tests (no k8s required) 15 | make lint # Run golangci-lint 16 | make lint-fix # Run linter with auto-fixes 17 | ``` 18 | 19 | ### Snapshot Testing 20 | The project uses go-snaps for snapshot tests. Update snapshots with: 21 | ```bash 22 | UPDATE_SNAPS=true make test 23 | ``` 24 | 25 | ### Development Dependencies 26 | ```bash 27 | docker compose up -d --build # Start local fake APIs (required for `make test` to work) 28 | ``` 29 | 30 | ## Architecture 31 | 32 | ### Core Components 33 | - **MaskinportenClient CRD**: Custom resource for managing Maskinporten OAuth clients 34 | - **Controller**: Reconciles MaskinportenClient resources with actual Maskinporten API 35 | - **Internal packages**: 36 | - `maskinporten/`: HTTP client for Maskinporten API integration 37 | - `config/`: Configuration management using koanf 38 | - `telemetry/`: OpenTelemetry instrumentation 39 | - `caching/`: Caching mechanisms 40 | - `crypto/`: Cryptographic operations 41 | - `operatorcontext/`: Operator context management 42 | 43 | ### Directory Structure 44 | - `api/v1alpha1/`: Kubernetes API definitions and CRD types 45 | - `internal/controller/`: Controller reconciliation logic 46 | - `config/`: Kubernetes manifests and Kustomize configurations 47 | - `test/e2e/`: End-to-end tests using Ginkgo/Gomega 48 | - `cmd/main.go`: Operator entry point 49 | 50 | ### Key Technologies 51 | - Kubebuilder framework for operator scaffolding 52 | - Controller-runtime for Kubernetes controller patterns 53 | - OpenTelemetry for observability 54 | - Azure Key Vault integration for secrets management 55 | - Ginkgo/Gomega for testing 56 | 57 | ## Testing Strategy 58 | 59 | - Unit tests alongside source files (`*_test.go`) 60 | - Snapshot testing with go-snaps for API responses 61 | - E2e tests in `test/e2e/` using Ginkgo framework 62 | - Fake services via Docker Compose for development 63 | -------------------------------------------------------------------------------- /internal/fakes/state.go: -------------------------------------------------------------------------------- 1 | package fakes 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | "sync" 8 | 9 | "github.com/altinn/altinn-k8s-operator/internal/config" 10 | "github.com/altinn/altinn-k8s-operator/internal/crypto" 11 | "github.com/altinn/altinn-k8s-operator/internal/maskinporten" 12 | ) 13 | 14 | type State struct { 15 | Db map[string]*Db 16 | Cfg *config.Config 17 | lock sync.Mutex 18 | } 19 | 20 | func (s *State) GetAll() map[string][]ClientRecord { 21 | s.lock.Lock() 22 | defer s.lock.Unlock() 23 | res := make(map[string][]ClientRecord) 24 | for runId, db := range s.Db { 25 | records := db.Query(func(ocr *ClientRecord) bool { 26 | return true 27 | }) 28 | res[runId] = records 29 | } 30 | return res 31 | } 32 | 33 | func (s *State) GetDb(req *http.Request) *Db { 34 | runId := req.Header.Get("X-Altinn-Operator-RunId") 35 | if runId == "" { 36 | log.Fatalf("Missing X-Altinn-Operator-RunId header in request: %v", req) 37 | } 38 | 39 | s.lock.Lock() 40 | defer s.lock.Unlock() 41 | var db *Db 42 | if existingDb, ok := s.Db[runId]; !ok { 43 | db = s.initDb() 44 | s.Db[runId] = db 45 | } else { 46 | db = existingDb 47 | } 48 | 49 | return db 50 | } 51 | 52 | func (s *State) initDb() *Db { 53 | db := NewDb() 54 | jwk := crypto.Jwk{} 55 | if err := json.Unmarshal([]byte(s.Cfg.MaskinportenApi.Jwk), &jwk); err != nil { 56 | log.Fatalf("couldn't unmarshal JWK: %v", err) 57 | } 58 | publicJwk := jwk.Public() 59 | 60 | integrationType := maskinporten.IntegrationTypeMaskinporten 61 | appType := maskinporten.ApplicationTypeWeb 62 | tokenEndpointMethod := maskinporten.TokenEndpointAuthMethodPrivateKeyJwt 63 | orgNo := "991825827" 64 | jwks := crypto.NewJwks(publicJwk) 65 | _, err := db.Insert(&maskinporten.AddClientRequest{ 66 | ClientName: &s.Cfg.MaskinportenApi.ClientId, 67 | ClientOrgno: &orgNo, 68 | GrantTypes: []maskinporten.GrantType{ 69 | maskinporten.GrantTypeJwtBearer, 70 | }, 71 | Scopes: []string{s.Cfg.MaskinportenApi.Scope}, 72 | IntegrationType: &integrationType, 73 | ApplicationType: &appType, 74 | TokenEndpointAuthMethod: &tokenEndpointMethod, 75 | }, jwks, s.Cfg.MaskinportenApi.ClientId) 76 | if err != nil { 77 | log.Fatalf("couldn't insert supplier client: %v", err) 78 | } 79 | 80 | return db 81 | } 82 | 83 | func NewState(cfg *config.Config) *State { 84 | return &State{ 85 | Db: make(map[string]*Db), 86 | Cfg: cfg, 87 | lock: sync.Mutex{}, 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/controller/suite_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "runtime" 7 | "testing" 8 | 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | 12 | "k8s.io/client-go/kubernetes/scheme" 13 | "k8s.io/client-go/rest" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | "sigs.k8s.io/controller-runtime/pkg/envtest" 16 | logf "sigs.k8s.io/controller-runtime/pkg/log" 17 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 18 | 19 | resourcesv1alpha1 "github.com/altinn/altinn-k8s-operator/api/v1alpha1" 20 | // +kubebuilder:scaffold:imports 21 | ) 22 | 23 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 24 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 25 | 26 | var cfg *rest.Config 27 | var k8sClient client.Client 28 | var testEnv *envtest.Environment 29 | 30 | func TestControllers(t *testing.T) { 31 | RegisterFailHandler(Fail) 32 | 33 | RunSpecs(t, "Controller Suite") 34 | } 35 | 36 | var _ = BeforeSuite(func() { 37 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 38 | 39 | By("bootstrapping test environment") 40 | testEnv = &envtest.Environment{ 41 | CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, 42 | ErrorIfCRDPathMissing: true, 43 | 44 | // The BinaryAssetsDirectory is only required if you want to run the tests directly 45 | // without call the makefile target test. If not informed it will look for the 46 | // default path defined in controller-runtime which is /usr/local/kubebuilder/. 47 | // Note that you must have the required binaries setup under the bin directory to perform 48 | // the tests directly. When we run make test it will be setup and used automatically. 49 | BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", 50 | fmt.Sprintf("1.30.0-%s-%s", runtime.GOOS, runtime.GOARCH)), 51 | } 52 | 53 | var err error 54 | // cfg is defined in this file globally. 55 | cfg, err = testEnv.Start() 56 | Expect(err).NotTo(HaveOccurred()) 57 | Expect(cfg).NotTo(BeNil()) 58 | 59 | err = resourcesv1alpha1.AddToScheme(scheme.Scheme) 60 | Expect(err).NotTo(HaveOccurred()) 61 | 62 | // +kubebuilder:scaffold:scheme 63 | 64 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 65 | Expect(err).NotTo(HaveOccurred()) 66 | Expect(k8sClient).NotTo(BeNil()) 67 | 68 | }) 69 | 70 | var _ = AfterSuite(func() { 71 | By("tearing down the test environment") 72 | err := testEnv.Stop() 73 | Expect(err).NotTo(HaveOccurred()) 74 | }) 75 | -------------------------------------------------------------------------------- /api/v1alpha1/maskinportenclient_types.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 5 | ) 6 | 7 | // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! 8 | // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. 9 | 10 | // MaskinportenClientSpec defines the desired state of MaskinportenClient 11 | type MaskinportenClientSpec struct { 12 | // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster 13 | // Important: Run "make" to regenerate code after modifying this file 14 | 15 | // Scopes is a list of Maskinporten scopes that the client should have access to 16 | Scopes []string `json:"scopes,omitempty"` 17 | } 18 | 19 | // MaskinportenClientStatus defines the observed state of MaskinportenClient 20 | type MaskinportenClientStatus struct { 21 | // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster 22 | // Important: Run "make" to regenerate code after modifying this file 23 | 24 | // ClientId is the client id of the client posted to Maskinporten API 25 | ClientId string `json:"clientId,omitempty"` 26 | Authority string `json:"authority,omitempty"` 27 | KeyIds []string `json:"keyIds,omitempty"` 28 | // LastSynced is the timestamp of the last successful sync towards Maskinporten API 29 | // 30 | // +kubebuilder:validation:Format: date-time 31 | LastSynced *metav1.Time `json:"lastSynced,omitempty"` 32 | State string `json:"state,omitempty"` 33 | Reason string `json:"reason,omitempty"` 34 | ObservedGeneration int64 `json:"observedGeneration,omitempty"` 35 | LastActions []string `json:"lastActions,omitempty"` 36 | } 37 | 38 | // +kubebuilder:object:root=true 39 | // +kubebuilder:subresource:status 40 | 41 | // MaskinportenClient is the Schema for the maskinportenclients API 42 | type MaskinportenClient struct { 43 | metav1.TypeMeta `json:",inline"` 44 | metav1.ObjectMeta `json:"metadata,omitempty"` 45 | 46 | Spec MaskinportenClientSpec `json:"spec,omitempty"` 47 | Status MaskinportenClientStatus `json:"status,omitempty"` 48 | } 49 | 50 | // +kubebuilder:object:root=true 51 | 52 | // MaskinportenClientList contains a list of MaskinportenClient 53 | type MaskinportenClientList struct { 54 | metav1.TypeMeta `json:",inline"` 55 | metav1.ListMeta `json:"metadata,omitempty"` 56 | Items []MaskinportenClient `json:"items"` 57 | } 58 | 59 | func init() { 60 | SchemeBuilder.Register(&MaskinportenClient{}, &MaskinportenClientList{}) 61 | } 62 | -------------------------------------------------------------------------------- /test/app/Makefile: -------------------------------------------------------------------------------- 1 | CHARTS_BRANCH ?= feature/maskinporten-integration 2 | 3 | .PHONY: download-charts 4 | download-charts: ## Download altinn-studio-charts repository 5 | @if [ ! -d "altinn-studio-charts" ]; then \ 6 | echo "Downloading altinn-studio-charts..."; \ 7 | git clone --depth 1 --branch $(CHARTS_BRANCH) https://github.com/Altinn/altinn-studio-charts.git ./altinn-studio-charts; \ 8 | else \ 9 | echo "Updating altinn-studio-charts..."; \ 10 | cd altinn-studio-charts && git pull origin $(CHARTS_BRANCH); \ 11 | fi 12 | 13 | .PHONY: create 14 | create: ## Initialize the test-application infra. 15 | kind delete cluster --name operator 16 | kind create cluster --config ./kind.config.yaml 17 | kubectl config use-context kind-operator 18 | helm repo add traefik https://traefik.github.io/charts 19 | sleep 1 20 | helm upgrade --install --force --version 26.1.0 traefik traefik/traefik --set ports.web.nodePort=30000 --set ports.websecure.nodePort=30001 --set ports.traefik.expose=true --set ports.traefik.nodePort=30002 --set service.type=NodePort 21 | cd ../../ && make install 22 | 23 | .PHONY: build 24 | build: ## Build the operator-testapp image. 25 | kubectl config use-context kind-operator 26 | docker build -t operator-testapp:latest . && helm dep up ./deployment/ 27 | kind load docker-image operator-testapp:latest --name operator 28 | 29 | # .PHONY: deps 30 | # deps: ## Install dependencies for the operator 31 | # cd ../../ 32 | # docker build -t operator-maskinporten-fakes:latest . 33 | # kind load docker-image operator-maskinporten-fakes:latest --name operator 34 | # kubectl run hazelcast --image=operator-maskinporten-fakes:latest --port=5701 35 | 36 | .PHONY: deploy 37 | deploy: build ## Deploy test-application to current kube context. 38 | kubectl config use-context kind-operator 39 | helm upgrade --install --force operator-testapp ./deployment --set deployment.image.repository=operator-testapp 40 | 41 | .PHONY: client 42 | client: ## Deploy a MaskinportenClient CRD to the cluster. 43 | kubectl config use-context kind-operator 44 | kubectl apply -f ../../config/samples/resources_v1alpha1_maskinportenclient.yaml 45 | 46 | .PHONY: delete-client 47 | delete-client: ## Delete a MaskinportenClient CRD on the cluster. 48 | kubectl config use-context kind-operator 49 | kubectl delete -f ../../config/samples/resources_v1alpha1_maskinportenclient.yaml 50 | 51 | .PHONY: destroy 52 | destroy: ## Destroy the test-application, including kind cluster. 53 | kubectl config use-context kind-operator 54 | helm uninstall --ignore-not-found local-testapp 55 | kind delete cluster --name operator 56 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/altinn/altinn-k8s-operator/internal/operatorcontext" 8 | "github.com/go-playground/validator/v10" 9 | ) 10 | 11 | type Config struct { 12 | MaskinportenApi MaskinportenApiConfig `koanf:"maskinporten_api" validate:"required"` 13 | Controller ControllerConfig `koanf:"controller" validate:"required"` 14 | } 15 | 16 | type MaskinportenApiConfig struct { 17 | ClientId string `koanf:"client_id" validate:"required"` 18 | AuthorityUrl string `koanf:"authority_url" validate:"required,http_url"` 19 | SelfServiceUrl string `koanf:"self_service_url" validate:"required,http_url"` 20 | Jwk string `koanf:"jwk" validate:"required,json"` 21 | Scope string `koanf:"scope" validate:"required"` 22 | } 23 | 24 | type ControllerConfig struct { 25 | RequeueAfter time.Duration `koanf:"requeue_after" validate:"required,min=5s,max=72h"` 26 | } 27 | 28 | type ConfigSource int 29 | 30 | const ( 31 | ConfigSourceDefault ConfigSource = iota 32 | ConfigSourceKoanf 33 | ConfigSourceAureKeyVault 34 | ) 35 | 36 | func GetConfig(operatorContext *operatorcontext.Context, source ConfigSource, configFilePath string) (*Config, error) { 37 | span := operatorContext.StartSpan("GetConfig") 38 | defer span.End() 39 | 40 | var cfg *Config 41 | var err error 42 | if source == ConfigSourceKoanf { 43 | cfg, err = loadFromKoanf(operatorContext, configFilePath) 44 | } else if source == ConfigSourceAureKeyVault { 45 | cfg, err = loadFromAzureKeyVault(operatorContext) 46 | } else if source == ConfigSourceDefault { 47 | if operatorContext.Environment == operatorcontext.EnvironmentLocal { 48 | cfg, err = loadFromKoanf(operatorContext, configFilePath) 49 | } else if operatorContext.Environment == operatorcontext.EnvironmentDev { 50 | cfg, err = loadFromKoanf(operatorContext, configFilePath) 51 | } else { 52 | return nil, fmt.Errorf("could not resolve default config source for env: %s", operatorContext.Environment) 53 | } 54 | } else { 55 | return nil, fmt.Errorf("invalid config source: %d", source) 56 | } 57 | 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | validate := validator.New(validator.WithRequiredStructEnabled()) 63 | 64 | if err := validate.Struct(cfg); err != nil { 65 | return nil, err 66 | } 67 | 68 | // k.Print() // Uncomment to print the config, only for debug, there be secrets 69 | 70 | return cfg, nil 71 | } 72 | 73 | func GetConfigOrDie(operatorContext *operatorcontext.Context, source ConfigSource, configFilePath string) *Config { 74 | cfg, err := GetConfig(operatorContext, source, configFilePath) 75 | if err != nil { 76 | panic(err) 77 | } 78 | return cfg 79 | } 80 | -------------------------------------------------------------------------------- /internal/crypto/jwks.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/rsa" 5 | "crypto/x509" 6 | "encoding/json" 7 | "time" 8 | 9 | "github.com/go-errors/errors" 10 | "github.com/go-jose/go-jose/v4" 11 | "github.com/jonboulle/clockwork" 12 | ) 13 | 14 | type Jwks struct { 15 | Keys []*Jwk `json:"keys"` 16 | } 17 | 18 | type Jwk struct { 19 | inner jose.JSONWebKey 20 | } 21 | 22 | func NewJwks(keys ...*Jwk) *Jwks { 23 | return &Jwks{Keys: keys} 24 | } 25 | 26 | func NewJwk(certificates []*x509.Certificate, key *rsa.PrivateKey, keyId string, use string, algorithm string) *Jwk { 27 | return &Jwk{ 28 | inner: jose.JSONWebKey{ 29 | Certificates: certificates, 30 | Key: key, 31 | KeyID: keyId, 32 | Use: use, 33 | Algorithm: algorithm, 34 | }, 35 | } 36 | } 37 | 38 | func (j *Jwk) MarshalJSON() ([]byte, error) { 39 | return json.Marshal(j.inner) 40 | } 41 | 42 | func (j *Jwk) UnmarshalJSON(b []byte) error { 43 | var inner jose.JSONWebKey 44 | if err := json.Unmarshal(b, &inner); err != nil { 45 | return err 46 | } 47 | 48 | j.inner = inner 49 | return nil 50 | } 51 | 52 | var _ json.Marshaler = (*Jwk)(nil) 53 | var _ json.Unmarshaler = (*Jwk)(nil) 54 | 55 | func (j *Jwk) KeyID() string { 56 | return j.inner.KeyID 57 | } 58 | 59 | func (j *Jwk) Algorithm() string { 60 | return j.inner.Algorithm 61 | } 62 | 63 | func (j *Jwk) IsPublic() bool { 64 | return j.inner.IsPublic() 65 | } 66 | 67 | func (j *Jwk) Public() *Jwk { 68 | if j.IsPublic() { 69 | return j 70 | } 71 | 72 | publicJwk := j.inner.Public() 73 | // We set certificates to nil here because Maskinporten doesn't want the 'x5c' field 74 | // which is marshalled from the Certificates field on the struct. 75 | publicJwk.Certificates = nil 76 | return &Jwk{inner: publicJwk} 77 | } 78 | 79 | func (j *Jwk) Certificates() []*x509.Certificate { 80 | if j == nil { 81 | return nil 82 | } 83 | 84 | return j.inner.Certificates 85 | } 86 | 87 | func (j *Jwk) NewJWT( 88 | audience []string, 89 | issuer string, 90 | scope string, 91 | expiry time.Time, 92 | clock clockwork.Clock, 93 | ) (string, error) { 94 | return NewJWT(j, audience, issuer, scope, expiry, clock) 95 | } 96 | 97 | func (j *Jwks) ToPublic() (*Jwks, error) { 98 | if j == nil { 99 | return nil, errors.New("can't create public keyset from JWKS when it is not initialized") 100 | } 101 | 102 | result := &Jwks{} 103 | result.Keys = make([]*Jwk, 0, len(j.Keys)) 104 | for _, jwk := range j.Keys { 105 | if jwk.IsPublic() { 106 | return nil, errors.New("keys in client info must be based on private/public key pairs") 107 | } 108 | publicJwk := jwk.Public() 109 | result.Keys = append(result.Keys, publicJwk) 110 | } 111 | 112 | return result, nil 113 | } 114 | -------------------------------------------------------------------------------- /internal/internal.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | crand "crypto/rand" 6 | 7 | "github.com/altinn/altinn-k8s-operator/internal/config" 8 | "github.com/altinn/altinn-k8s-operator/internal/crypto" 9 | "github.com/altinn/altinn-k8s-operator/internal/maskinporten" 10 | "github.com/altinn/altinn-k8s-operator/internal/operatorcontext" 11 | rt "github.com/altinn/altinn-k8s-operator/internal/runtime" 12 | "github.com/altinn/altinn-k8s-operator/internal/telemetry" 13 | "github.com/jonboulle/clockwork" 14 | "go.opentelemetry.io/otel" 15 | "go.opentelemetry.io/otel/metric" 16 | "go.opentelemetry.io/otel/trace" 17 | ) 18 | 19 | type runtime struct { 20 | config config.Config 21 | operatorContext operatorcontext.Context 22 | crypto crypto.CryptoService 23 | maskinportenApiClient *maskinporten.HttpApiClient 24 | tracer trace.Tracer 25 | meter metric.Meter 26 | clock clockwork.Clock 27 | } 28 | 29 | var _ rt.Runtime = (*runtime)(nil) 30 | 31 | func NewRuntime(ctx context.Context, env string) (rt.Runtime, error) { 32 | tracer := otel.Tracer(telemetry.ServiceName) 33 | ctx, span := tracer.Start(ctx, "NewRuntime") 34 | defer span.End() 35 | 36 | operatorContext, err := operatorcontext.Discover(ctx) 37 | if err != nil { 38 | return nil, err 39 | } 40 | if env != "" { 41 | operatorContext.OverrideEnvironment(env) 42 | } 43 | 44 | cfg, err := config.GetConfig(operatorContext, config.ConfigSourceDefault, "") 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | clock := clockwork.NewRealClock() 50 | 51 | cryptoRand := crand.Reader 52 | 53 | crypto := crypto.NewDefaultService( 54 | operatorContext, 55 | clock, 56 | cryptoRand, 57 | ) 58 | 59 | maskinportenApiClient, err := maskinporten.NewHttpApiClient(&cfg.MaskinportenApi, operatorContext, clock) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | rt := &runtime{ 65 | config: *cfg, 66 | operatorContext: *operatorContext, 67 | crypto: *crypto, 68 | maskinportenApiClient: maskinportenApiClient, 69 | tracer: tracer, 70 | meter: otel.Meter(telemetry.ServiceName), 71 | clock: clock, 72 | } 73 | 74 | return rt, nil 75 | } 76 | 77 | func (r *runtime) GetConfig() *config.Config { 78 | return &r.config 79 | } 80 | 81 | func (r *runtime) GetOperatorContext() *operatorcontext.Context { 82 | return &r.operatorContext 83 | } 84 | 85 | func (r *runtime) GetCrypto() *crypto.CryptoService { 86 | return &r.crypto 87 | } 88 | 89 | func (r *runtime) GetClock() clockwork.Clock { 90 | return r.clock 91 | } 92 | 93 | func (r *runtime) GetMaskinportenApiClient() *maskinporten.HttpApiClient { 94 | return r.maskinportenApiClient 95 | } 96 | 97 | func (r *runtime) Tracer() trace.Tracer { 98 | return r.tracer 99 | } 100 | 101 | func (r *runtime) Meter() metric.Meter { 102 | return r.meter 103 | } 104 | -------------------------------------------------------------------------------- /config/crd/bases/resources.altinn.studio_maskinportenclients.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.15.0 7 | name: maskinportenclients.resources.altinn.studio 8 | spec: 9 | group: resources.altinn.studio 10 | names: 11 | kind: MaskinportenClient 12 | listKind: MaskinportenClientList 13 | plural: maskinportenclients 14 | singular: maskinportenclient 15 | scope: Namespaced 16 | versions: 17 | - name: v1alpha1 18 | schema: 19 | openAPIV3Schema: 20 | description: MaskinportenClient is the Schema for the maskinportenclients 21 | API 22 | properties: 23 | apiVersion: 24 | description: |- 25 | APIVersion defines the versioned schema of this representation of an object. 26 | Servers should convert recognized schemas to the latest internal value, and 27 | may reject unrecognized values. 28 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 29 | type: string 30 | kind: 31 | description: |- 32 | Kind is a string value representing the REST resource this object represents. 33 | Servers may infer this from the endpoint the client submits requests to. 34 | Cannot be updated. 35 | In CamelCase. 36 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 37 | type: string 38 | metadata: 39 | type: object 40 | spec: 41 | description: MaskinportenClientSpec defines the desired state of MaskinportenClient 42 | properties: 43 | scopes: 44 | description: Scopes is a list of Maskinporten scopes that the client 45 | should have access to 46 | items: 47 | type: string 48 | type: array 49 | type: object 50 | status: 51 | description: MaskinportenClientStatus defines the observed state of MaskinportenClient 52 | properties: 53 | authority: 54 | type: string 55 | clientId: 56 | description: ClientId is the client id of the client posted to Maskinporten 57 | API 58 | type: string 59 | keyIds: 60 | items: 61 | type: string 62 | type: array 63 | lastActions: 64 | items: 65 | type: string 66 | type: array 67 | lastSynced: 68 | description: LastSynced is the timestamp of the last successful sync 69 | towards Maskinporten API 70 | format: date-time 71 | type: string 72 | observedGeneration: 73 | format: int64 74 | type: integer 75 | reason: 76 | type: string 77 | state: 78 | type: string 79 | type: object 80 | type: object 81 | served: true 82 | storage: true 83 | subresources: 84 | status: {} 85 | -------------------------------------------------------------------------------- /internal/config/azure_keyvault.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/Azure/azure-sdk-for-go/sdk/azcore" 8 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 9 | "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" 10 | "github.com/altinn/altinn-k8s-operator/internal/operatorcontext" 11 | ) 12 | 13 | func loadFromAzureKeyVault(operatorContext *operatorcontext.Context) (*Config, error) { 14 | span := operatorContext.StartSpan("GetConfig.AzureKeyVault") 15 | defer span.End() 16 | 17 | var cred azcore.TokenCredential 18 | var err error 19 | 20 | if operatorContext.IsLocal() { 21 | cred, err = azidentity.NewDefaultAzureCredential(nil) 22 | } else { 23 | cred, err = azidentity.NewWorkloadIdentityCredential(nil) 24 | } 25 | 26 | if err != nil { 27 | return nil, fmt.Errorf("error getting credentials for loading config: %w", err) 28 | } 29 | 30 | url := fmt.Sprintf("https://altinn-%s-operator-kv.vault.azure.net", operatorContext.Environment) 31 | client, err := azsecrets.NewClient(url, cred, nil) 32 | if err != nil { 33 | return nil, fmt.Errorf("error building client for Azure KV: %w", err) 34 | } 35 | 36 | config := &Config{} 37 | err = loadMaskinportenApiFromAzureKeyVault(operatorContext, client, config) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | err = loadControllerFromAzureKeyVault(operatorContext, client, config) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return config, nil 48 | } 49 | 50 | func loadMaskinportenApiFromAzureKeyVault( 51 | operatorContext *operatorcontext.Context, 52 | client *azsecrets.Client, 53 | config *Config, 54 | ) error { 55 | secretKeys := []string{"ClientId", "Url", "Jwk", "Scope"} 56 | 57 | for _, secretKey := range secretKeys { 58 | secret, err := client.GetSecret( 59 | operatorContext.Context, 60 | fmt.Sprintf("%s.%s", "MaskinportenApi", secretKey), 61 | "", 62 | nil, 63 | ) 64 | if err != nil { 65 | return fmt.Errorf("error getting secret: %s, %w", secretKey, err) 66 | } 67 | 68 | switch secretKey { 69 | case fmt.Sprintf("%s.%s", "MaskinportenApi", "ClientId"): 70 | config.MaskinportenApi.ClientId = *secret.Value 71 | case fmt.Sprintf("%s.%s", "MaskinportenApi", "AuthorityUrl"): 72 | config.MaskinportenApi.AuthorityUrl = *secret.Value 73 | case fmt.Sprintf("%s.%s", "MaskinportenApi", "Jwk"): 74 | config.MaskinportenApi.Jwk = *secret.Value 75 | case fmt.Sprintf("%s.%s", "MaskinportenApi", "Scope"): 76 | config.MaskinportenApi.Scope = *secret.Value 77 | } 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func loadControllerFromAzureKeyVault( 84 | operatorContext *operatorcontext.Context, 85 | client *azsecrets.Client, 86 | config *Config, 87 | ) error { 88 | secretKeys := []string{"RequeueAfter"} 89 | 90 | for _, secretKey := range secretKeys { 91 | secret, err := client.GetSecret( 92 | operatorContext.Context, 93 | fmt.Sprintf("%s.%s", "Controller", secretKey), 94 | "", 95 | nil, 96 | ) 97 | if err != nil { 98 | return fmt.Errorf("error getting secret: %s, %w", secretKey, err) 99 | } 100 | 101 | switch secretKey { 102 | case fmt.Sprintf("%s.%s", "Controller", "RequeueAfter"): 103 | config.Controller.RequeueAfter, err = time.ParseDuration(*secret.Value) 104 | if err != nil { 105 | return err 106 | } 107 | } 108 | } 109 | 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: altinn-k8s-operator 7 | app.kubernetes.io/managed-by: kustomize 8 | name: system 9 | --- 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | metadata: 13 | name: controller-manager 14 | namespace: system 15 | labels: 16 | control-plane: controller-manager 17 | app.kubernetes.io/name: altinn-k8s-operator 18 | app.kubernetes.io/managed-by: kustomize 19 | spec: 20 | selector: 21 | matchLabels: 22 | control-plane: controller-manager 23 | replicas: 1 24 | template: 25 | metadata: 26 | annotations: 27 | kubectl.kubernetes.io/default-container: manager 28 | labels: 29 | control-plane: controller-manager 30 | spec: 31 | # TODO(user): Uncomment the following code to configure the nodeAffinity expression 32 | # according to the platforms which are supported by your solution. 33 | # It is considered best practice to support multiple architectures. You can 34 | # build your manager image using the makefile target docker-buildx. 35 | # affinity: 36 | # nodeAffinity: 37 | # requiredDuringSchedulingIgnoredDuringExecution: 38 | # nodeSelectorTerms: 39 | # - matchExpressions: 40 | # - key: kubernetes.io/arch 41 | # operator: In 42 | # values: 43 | # - amd64 44 | # - arm64 45 | # - ppc64le 46 | # - s390x 47 | # - key: kubernetes.io/os 48 | # operator: In 49 | # values: 50 | # - linux 51 | securityContext: 52 | runAsNonRoot: true 53 | # TODO(user): For common cases that do not require escalating privileges 54 | # it is recommended to ensure that all your Pods/Containers are restrictive. 55 | # More info: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted 56 | # Please uncomment the following code if your project does NOT have to work on old Kubernetes 57 | # versions < 1.19 or on vendors versions which do NOT support this field by default (i.e. Openshift < 4.11 ). 58 | # seccompProfile: 59 | # type: RuntimeDefault 60 | containers: 61 | - command: 62 | - /manager 63 | args: 64 | - --leader-elect 65 | - --health-probe-bind-address=:8081 66 | image: controller:latest 67 | name: manager 68 | securityContext: 69 | allowPrivilegeEscalation: false 70 | capabilities: 71 | drop: 72 | - "ALL" 73 | livenessProbe: 74 | httpGet: 75 | path: /healthz 76 | port: 8081 77 | initialDelaySeconds: 15 78 | periodSeconds: 20 79 | readinessProbe: 80 | httpGet: 81 | path: /readyz 82 | port: 8081 83 | initialDelaySeconds: 5 84 | periodSeconds: 10 85 | # TODO(user): Configure the resources accordingly based on the project requirements. 86 | # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ 87 | resources: 88 | limits: 89 | cpu: 500m 90 | memory: 128Mi 91 | requests: 92 | cpu: 10m 93 | memory: 64Mi 94 | serviceAccountName: controller-manager 95 | terminationGracePeriodSeconds: 10 96 | -------------------------------------------------------------------------------- /api/v1alpha1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | // Code generated by controller-gen. DO NOT EDIT. 4 | 5 | package v1alpha1 6 | 7 | import ( 8 | runtime "k8s.io/apimachinery/pkg/runtime" 9 | ) 10 | 11 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 12 | func (in *MaskinportenClient) DeepCopyInto(out *MaskinportenClient) { 13 | *out = *in 14 | out.TypeMeta = in.TypeMeta 15 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 16 | in.Spec.DeepCopyInto(&out.Spec) 17 | in.Status.DeepCopyInto(&out.Status) 18 | } 19 | 20 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MaskinportenClient. 21 | func (in *MaskinportenClient) DeepCopy() *MaskinportenClient { 22 | if in == nil { 23 | return nil 24 | } 25 | out := new(MaskinportenClient) 26 | in.DeepCopyInto(out) 27 | return out 28 | } 29 | 30 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 31 | func (in *MaskinportenClient) DeepCopyObject() runtime.Object { 32 | if c := in.DeepCopy(); c != nil { 33 | return c 34 | } 35 | return nil 36 | } 37 | 38 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 39 | func (in *MaskinportenClientList) DeepCopyInto(out *MaskinportenClientList) { 40 | *out = *in 41 | out.TypeMeta = in.TypeMeta 42 | in.ListMeta.DeepCopyInto(&out.ListMeta) 43 | if in.Items != nil { 44 | in, out := &in.Items, &out.Items 45 | *out = make([]MaskinportenClient, len(*in)) 46 | for i := range *in { 47 | (*in)[i].DeepCopyInto(&(*out)[i]) 48 | } 49 | } 50 | } 51 | 52 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MaskinportenClientList. 53 | func (in *MaskinportenClientList) DeepCopy() *MaskinportenClientList { 54 | if in == nil { 55 | return nil 56 | } 57 | out := new(MaskinportenClientList) 58 | in.DeepCopyInto(out) 59 | return out 60 | } 61 | 62 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 63 | func (in *MaskinportenClientList) DeepCopyObject() runtime.Object { 64 | if c := in.DeepCopy(); c != nil { 65 | return c 66 | } 67 | return nil 68 | } 69 | 70 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 71 | func (in *MaskinportenClientSpec) DeepCopyInto(out *MaskinportenClientSpec) { 72 | *out = *in 73 | if in.Scopes != nil { 74 | in, out := &in.Scopes, &out.Scopes 75 | *out = make([]string, len(*in)) 76 | copy(*out, *in) 77 | } 78 | } 79 | 80 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MaskinportenClientSpec. 81 | func (in *MaskinportenClientSpec) DeepCopy() *MaskinportenClientSpec { 82 | if in == nil { 83 | return nil 84 | } 85 | out := new(MaskinportenClientSpec) 86 | in.DeepCopyInto(out) 87 | return out 88 | } 89 | 90 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 91 | func (in *MaskinportenClientStatus) DeepCopyInto(out *MaskinportenClientStatus) { 92 | *out = *in 93 | if in.KeyIds != nil { 94 | in, out := &in.KeyIds, &out.KeyIds 95 | *out = make([]string, len(*in)) 96 | copy(*out, *in) 97 | } 98 | if in.LastSynced != nil { 99 | in, out := &in.LastSynced, &out.LastSynced 100 | *out = (*in).DeepCopy() 101 | } 102 | if in.LastActions != nil { 103 | in, out := &in.LastActions, &out.LastActions 104 | *out = make([]string, len(*in)) 105 | copy(*out, *in) 106 | } 107 | } 108 | 109 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MaskinportenClientStatus. 110 | func (in *MaskinportenClientStatus) DeepCopy() *MaskinportenClientStatus { 111 | if in == nil { 112 | return nil 113 | } 114 | out := new(MaskinportenClientStatus) 115 | in.DeepCopyInto(out) 116 | return out 117 | } 118 | -------------------------------------------------------------------------------- /internal/telemetry/telemetry.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/go-errors/errors" 10 | 11 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 12 | "go.opentelemetry.io/otel" 13 | "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" 14 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" 15 | "go.opentelemetry.io/otel/propagation" 16 | "go.opentelemetry.io/otel/sdk/metric" 17 | "go.opentelemetry.io/otel/sdk/resource" 18 | "go.opentelemetry.io/otel/sdk/trace" 19 | semconv "go.opentelemetry.io/otel/semconv/v1.17.0" 20 | "k8s.io/client-go/rest" 21 | ) 22 | 23 | const ServiceName string = "altinn-k8s-operator" 24 | 25 | // ConfigureOTel bootstraps the OpenTelemetry pipeline. 26 | // If it does not return an error, make sure to call shutdown for proper cleanup. 27 | func ConfigureOTel(ctx context.Context) (shutdown func(context.Context) error, err error) { 28 | var shutdownFuncs []func(context.Context) error 29 | 30 | // shutdown calls cleanup functions registered via shutdownFuncs. 31 | // The errors from the calls are joined. 32 | // Each registered cleanup will be invoked once. 33 | shutdown = func(ctx context.Context) error { 34 | var err error 35 | for _, fn := range shutdownFuncs { 36 | err = errors.Join(err, fn(ctx)) 37 | } 38 | shutdownFuncs = nil 39 | return err 40 | } 41 | 42 | // handleErr calls shutdown for cleanup and makes sure that all errors are returned. 43 | handleErr := func(inErr error) { 44 | err = errors.Join(inErr, shutdown(ctx)) 45 | } 46 | 47 | res, err := resource.New(ctx, 48 | resource.WithAttributes( 49 | // the service name used to display traces in backends 50 | semconv.ServiceName(ServiceName), 51 | ), 52 | ) 53 | if err != nil { 54 | return nil, fmt.Errorf("failed to create resource: %w", err) 55 | } 56 | 57 | // Set up propagator. 58 | prop := newPropagator() 59 | otel.SetTextMapPropagator(prop) 60 | 61 | // Set up trace provider. 62 | tracerProvider, err := newTraceProvider(ctx, res) 63 | if err != nil { 64 | handleErr(err) 65 | return shutdown, err 66 | } 67 | shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown) 68 | otel.SetTracerProvider(tracerProvider) 69 | 70 | // Set up meter provider. 71 | meterProvider, err := newMeterProvider(ctx, res) 72 | if err != nil { 73 | handleErr(err) 74 | return shutdown, err 75 | } 76 | shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown) 77 | otel.SetMeterProvider(meterProvider) 78 | 79 | return shutdown, err 80 | } 81 | 82 | func WrapTransport(config *rest.Config) { 83 | config.Wrap(func(rt http.RoundTripper) http.RoundTripper { 84 | return otelhttp.NewTransport(rt) 85 | }) 86 | } 87 | 88 | func newPropagator() propagation.TextMapPropagator { 89 | return propagation.NewCompositeTextMapPropagator( 90 | propagation.TraceContext{}, 91 | propagation.Baggage{}, 92 | ) 93 | } 94 | 95 | func newTraceProvider(ctx context.Context, res *resource.Resource) (*trace.TracerProvider, error) { 96 | traceExporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithInsecure()) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | traceProvider := trace.NewTracerProvider( 102 | trace.WithBatcher(traceExporter, 103 | // Default is 5s. Set to 1s for demonstrative purposes. 104 | trace.WithBatchTimeout(time.Second)), trace.WithResource(res), 105 | ) 106 | return traceProvider, nil 107 | } 108 | 109 | func newMeterProvider(ctx context.Context, res *resource.Resource) (*metric.MeterProvider, error) { 110 | metricExporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithInsecure()) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | meterProvider := metric.NewMeterProvider( 116 | metric.WithReader(metric.NewPeriodicReader(metricExporter, 117 | // Default is 1m. Set to 3s for demonstrative purposes. 118 | metric.WithInterval(5*time.Second))), metric.WithResource(res), 119 | ) 120 | return meterProvider, nil 121 | } 122 | -------------------------------------------------------------------------------- /internal/crypto/jwt.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "slices" 5 | "time" 6 | 7 | "github.com/altinn/altinn-k8s-operator/internal/assert" 8 | "github.com/go-errors/errors" 9 | "github.com/go-jose/go-jose/v4" 10 | "github.com/go-jose/go-jose/v4/jwt" 11 | "github.com/google/uuid" 12 | "github.com/jonboulle/clockwork" 13 | ) 14 | 15 | var SignatureAlgorithms []jose.SignatureAlgorithm = []jose.SignatureAlgorithm{jose.RS256, jose.RS384, jose.RS512} 16 | var SignatureAlgorithmsStr []string = []string{string(jose.RS256), string(jose.RS384), string(jose.RS512)} 17 | 18 | type Claims struct { 19 | Audience []string `json:"aud"` 20 | Issuer string `json:"iss"` 21 | Subject string `json:"sub,omitempty"` 22 | IssuedAt time.Time `json:"iat"` 23 | NotBefore time.Time `json:"nbf"` 24 | Expiry time.Time `json:"exp"` 25 | ID string `json:"jti"` 26 | Scope string `json:"scope,omitempty"` 27 | } 28 | 29 | type Jwt struct { 30 | token jwt.JSONWebToken 31 | } 32 | 33 | func ParseJWT(tokenString string) (*Jwt, error) { 34 | token, err := jwt.ParseSigned(tokenString, SignatureAlgorithms) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | if len(token.Headers) != 1 { 40 | return nil, errors.New("unexpected number of headers in JWT") 41 | } 42 | 43 | return &Jwt{token: *token}, nil 44 | } 45 | 46 | func (j *Jwt) DecodeClaims(jwk *Jwk) (*Claims, error) { 47 | if jwk == nil { 48 | return nil, errors.New("JWK cannot be nil") 49 | } 50 | 51 | var joseClaims jwt.Claims 52 | if err := j.token.Claims(jwk.inner, &joseClaims); err != nil { 53 | return nil, err 54 | } 55 | 56 | // Convert from go-jose claims to our encapsulated claims 57 | claims := Claims{ 58 | Audience: joseClaims.Audience, 59 | Issuer: joseClaims.Issuer, 60 | Subject: joseClaims.Subject, 61 | ID: joseClaims.ID, 62 | Scope: "", // Will be extracted from private claims if present 63 | } 64 | 65 | if joseClaims.IssuedAt != nil { 66 | claims.IssuedAt = joseClaims.IssuedAt.Time() 67 | } 68 | if joseClaims.NotBefore != nil { 69 | claims.NotBefore = joseClaims.NotBefore.Time() 70 | } 71 | if joseClaims.Expiry != nil { 72 | claims.Expiry = joseClaims.Expiry.Time() 73 | } 74 | 75 | // Extract private claims (like scope) 76 | var privateClaims map[string]interface{} 77 | if err := j.token.Claims(jwk.inner, &privateClaims); err == nil { 78 | if scope, ok := privateClaims["scope"].(string); ok { 79 | claims.Scope = scope 80 | } 81 | } 82 | return &claims, nil 83 | } 84 | 85 | func (j *Jwt) KeyID() string { 86 | assert.AssertWith(len(j.token.Headers) == 1, "unexpected number of headers in JWT") 87 | return j.token.Headers[0].KeyID 88 | } 89 | 90 | func NewJWT( 91 | jwk *Jwk, 92 | audience []string, 93 | issuer string, 94 | scope string, 95 | expiry time.Time, 96 | clock clockwork.Clock, 97 | ) (string, error) { 98 | if jwk.IsPublic() { 99 | return "", errors.New("cannot sign JWT with public key") 100 | } 101 | 102 | issuedAt := clock.Now() 103 | 104 | pubClaims := jwt.Claims{ 105 | Audience: audience, 106 | Issuer: issuer, 107 | IssuedAt: jwt.NewNumericDate(issuedAt), 108 | NotBefore: jwt.NewNumericDate(issuedAt), 109 | Expiry: jwt.NewNumericDate(expiry), 110 | ID: uuid.New().String(), 111 | } 112 | 113 | privClaims := struct { 114 | Scope string `json:"scope"` 115 | }{ 116 | Scope: scope, 117 | } 118 | 119 | algo := jwk.Algorithm() 120 | if !slices.Contains(SignatureAlgorithmsStr, algo) { 121 | return "", errors.New("unsupported signing algorithm") 122 | } 123 | 124 | signer, err := jose.NewSigner( 125 | jose.SigningKey{Algorithm: jose.SignatureAlgorithm(jwk.Algorithm()), Key: jwk.inner}, 126 | (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", jwk.KeyID()), 127 | ) 128 | if err != nil { 129 | return "", err 130 | } 131 | 132 | signedToken, err := jwt.Signed(signer).Claims(pubClaims).Claims(privClaims).Serialize() 133 | if err != nil { 134 | return "", err 135 | } 136 | 137 | return signedToken, nil 138 | } 139 | -------------------------------------------------------------------------------- /internal/fakes/db.go: -------------------------------------------------------------------------------- 1 | package fakes 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/altinn/altinn-k8s-operator/internal/crypto" 7 | "github.com/altinn/altinn-k8s-operator/internal/maskinporten" 8 | "github.com/go-errors/errors" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | var InvalidClientName = errors.Errorf("invalid client ID") 13 | var ClientAlreadyExists = errors.Errorf("client already exists") 14 | 15 | const SupplierOrgNo string = "11111111" 16 | 17 | type Db struct { 18 | Clients []ClientRecord 19 | ClientIdIndex map[string]int 20 | } 21 | 22 | type ClientRecord struct { 23 | ClientId string 24 | Client *maskinporten.ClientResponse 25 | Jwks *crypto.Jwks 26 | } 27 | 28 | func NewDb() *Db { 29 | return &Db{ 30 | Clients: make([]ClientRecord, 0, 64), 31 | ClientIdIndex: make(map[string]int, 64), 32 | } 33 | } 34 | 35 | func (d *Db) Insert( 36 | req *maskinporten.AddClientRequest, 37 | jwks *crypto.Jwks, 38 | overrideClientId string, 39 | ) (*ClientRecord, error) { 40 | if req.ClientName == nil || *req.ClientName == "" { 41 | return nil, errors.New(InvalidClientName) 42 | } 43 | var clientId string 44 | if overrideClientId != "" { 45 | clientId = overrideClientId 46 | } else { 47 | clientId = uuid.New().String() 48 | } 49 | _, ok := d.ClientIdIndex[clientId] 50 | if ok { 51 | return nil, errors.New(ClientAlreadyExists) 52 | } 53 | 54 | supplierOrg := SupplierOrgNo 55 | now := time.Now() 56 | active := true 57 | jwksUri := "" 58 | client := &maskinporten.ClientResponse{ 59 | ClientId: clientId, 60 | ClientName: req.ClientName, 61 | LogoUri: req.LogoUri, 62 | Description: req.Description, 63 | Scopes: req.Scopes, 64 | RedirectUris: req.RedirectUris, 65 | PostLogoutRedirectUris: req.PostLogoutRedirectUris, 66 | AuthorizationLifetime: req.AuthorizationLifetime, 67 | AccessTokenLifetime: req.AccessTokenLifetime, 68 | RefreshTokenLifetime: req.RefreshTokenLifetime, 69 | RefreshTokenUsage: req.RefreshTokenUsage, 70 | FrontchannelLogoutUri: req.FrontchannelLogoutUri, 71 | FrontchannelLogoutSessionRequired: req.FrontchannelLogoutSessionRequired, 72 | TokenEndpointAuthMethod: req.TokenEndpointAuthMethod, 73 | GrantTypes: req.GrantTypes, 74 | IntegrationType: req.IntegrationType, 75 | ApplicationType: req.ApplicationType, 76 | SsoDisabled: req.SsoDisabled, 77 | CodeChallengeMethod: req.CodeChallengeMethod, 78 | LastUpdated: &now, 79 | Created: &now, 80 | ClientSecret: nil, 81 | ClientOrgno: req.ClientOrgno, 82 | SupplierOrgno: &supplierOrg, 83 | Active: &active, 84 | JwksUri: &jwksUri, 85 | } 86 | 87 | record := ClientRecord{ 88 | ClientId: clientId, 89 | Client: client, 90 | Jwks: jwks, 91 | } 92 | 93 | idx := len(d.Clients) 94 | d.Clients = append(d.Clients, record) 95 | d.ClientIdIndex[clientId] = idx 96 | return &record, nil 97 | } 98 | 99 | func (d *Db) UpdateJwks(clientId string, jwks *crypto.Jwks) error { 100 | i, ok := d.ClientIdIndex[clientId] 101 | if !ok { 102 | return errors.New("client not found") 103 | } 104 | 105 | d.Clients[i].Jwks = jwks 106 | return nil 107 | } 108 | 109 | func (d *Db) Delete(clientId string) bool { 110 | i, ok := d.ClientIdIndex[clientId] 111 | if !ok { 112 | return false 113 | } 114 | 115 | delete(d.ClientIdIndex, clientId) 116 | 117 | d.Clients[i] = d.Clients[len(d.Clients)-1] 118 | d.Clients = d.Clients[:len(d.Clients)-1] 119 | return true 120 | } 121 | 122 | func (d *Db) Get(clientId string) *ClientRecord { 123 | i, ok := d.ClientIdIndex[clientId] 124 | if !ok { 125 | return nil 126 | } 127 | 128 | client := &d.Clients[i] 129 | if client.ClientId != clientId { 130 | panic("inconsistent state") 131 | } 132 | 133 | return client 134 | } 135 | 136 | func (d *Db) Query(predicate func(*ClientRecord) bool) []ClientRecord { 137 | result := make([]ClientRecord, 0, 4) 138 | for i := 0; i < len(d.Clients); i++ { 139 | client := &d.Clients[i] 140 | if predicate(client) { 141 | result = append(result, *client) 142 | } 143 | } 144 | return result 145 | } 146 | -------------------------------------------------------------------------------- /test/e2e/e2e_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os/exec" 7 | "time" 8 | 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | 12 | resourcesv1alpha1 "github.com/altinn/altinn-k8s-operator/api/v1alpha1" 13 | "github.com/altinn/altinn-k8s-operator/test/utils" 14 | ) 15 | 16 | const namespace = "altinn-k8s-operator-system" 17 | 18 | var _ = Describe("controller", Ordered, func() { 19 | BeforeAll(func() { 20 | By("installing kind cluster") 21 | Expect(utils.StartKindCluster()).To(Succeed()) 22 | 23 | By("installing test app") 24 | Expect(utils.StartTestApp()).To(Succeed()) 25 | 26 | By("creating manager namespace") 27 | cmd := exec.Command("kubectl", "create", "ns", namespace) 28 | _, err := utils.Run(cmd, "") 29 | Expect(err).To(Succeed()) 30 | }) 31 | 32 | // AfterAll(func() { 33 | // By("uninstalling kind cluster") 34 | // Expect(utils.Destroy()).To(Succeed()) 35 | // }) 36 | 37 | Context("Operator", func() { 38 | It("should run successfully", func() { 39 | var controllerPodName string 40 | var err error 41 | 42 | // projectimage stores the name of the image used in the example 43 | var projectimage = "example.com/altinn-k8s-operator:v0.0.1" 44 | 45 | By("building the manager(Operator) image") 46 | cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectimage)) 47 | _, err = utils.Run(cmd, "") 48 | ExpectWithOffset(1, err).NotTo(HaveOccurred()) 49 | 50 | By("loading the the manager(Operator) image on Kind") 51 | err = utils.LoadImageToKindClusterWithName(projectimage) 52 | ExpectWithOffset(1, err).NotTo(HaveOccurred()) 53 | 54 | By("deploying the controller-manager") 55 | cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectimage)) 56 | _, err = utils.Run(cmd, "") 57 | ExpectWithOffset(1, err).NotTo(HaveOccurred()) 58 | 59 | By("validating that the controller-manager pod is running as expected") 60 | verifyControllerUp := func() error { 61 | // Get pod name 62 | 63 | cmd = exec.Command("kubectl", "get", 64 | "pods", "-l", "control-plane=controller-manager", 65 | "-o", "go-template={{ range .items }}"+ 66 | "{{ if not .metadata.deletionTimestamp }}"+ 67 | "{{ .metadata.name }}"+ 68 | "{{ \"\\n\" }}{{ end }}{{ end }}", 69 | "-n", namespace, 70 | ) 71 | 72 | podOutput, err := utils.Run(cmd, "") 73 | ExpectWithOffset(2, err).NotTo(HaveOccurred()) 74 | podNames := utils.GetNonEmptyLines(string(podOutput)) 75 | if len(podNames) != 1 { 76 | return fmt.Errorf("expect 1 controller pods running, but got %d", len(podNames)) 77 | } 78 | controllerPodName = podNames[0] 79 | ExpectWithOffset(2, controllerPodName).Should(ContainSubstring("controller-manager")) 80 | 81 | // Validate pod status 82 | cmd = exec.Command("kubectl", "get", 83 | "pods", controllerPodName, "-o", "jsonpath={.status.phase}", 84 | "-n", namespace, 85 | ) 86 | status, err := utils.Run(cmd, "") 87 | ExpectWithOffset(2, err).NotTo(HaveOccurred()) 88 | if string(status) != "Running" { 89 | return fmt.Errorf("controller pod in %s status", status) 90 | } 91 | return nil 92 | } 93 | EventuallyWithOffset(1, verifyControllerUp, time.Minute, time.Second).Should(Succeed()) 94 | }) 95 | 96 | It("should respond to MaskinportenClient", func() { 97 | 98 | By("creating MaskinportenClient resource") 99 | Expect(utils.ApplyMaskinportenClient()).To(Succeed()) 100 | 101 | By("constructing k8s client") 102 | k8sClient, err := utils.GetK8sClient() 103 | Expect(err).To(Succeed()) 104 | 105 | By("validating that the corresponding status is updated after reconcile") 106 | verifyStatusUpdated := func() error { 107 | maskinportenClient := &resourcesv1alpha1.MaskinportenClient{} 108 | err := k8sClient.Get(). 109 | Resource("maskinportenclients"). 110 | Namespace("default"). 111 | Name("local-testapp"). 112 | Do(context.Background()). 113 | Into(maskinportenClient) 114 | 115 | if err != nil { 116 | return err 117 | } 118 | 119 | state := maskinportenClient.Status.State 120 | if state != "reconciled" { 121 | return fmt.Errorf("MaskinportenClient resource in %s status", state) 122 | } 123 | 124 | return nil 125 | } 126 | EventuallyWithOffset( 127 | 1, 128 | verifyStatusUpdated, 129 | ).WithTimeout(time.Second * 5). 130 | WithPolling(time.Second). 131 | Should(Succeed()) 132 | }) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /test/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "os/exec" 8 | "path" 9 | "path/filepath" 10 | "strings" 11 | 12 | resourcesv1alpha1 "github.com/altinn/altinn-k8s-operator/api/v1alpha1" 13 | . "github.com/onsi/ginkgo/v2" //nolint:golint,revive 14 | "golang.org/x/exp/rand" 15 | "k8s.io/apimachinery/pkg/runtime/schema" 16 | "k8s.io/apimachinery/pkg/runtime/serializer" 17 | "k8s.io/client-go/kubernetes/scheme" 18 | "k8s.io/client-go/rest" 19 | "k8s.io/client-go/tools/clientcmd" 20 | "k8s.io/client-go/util/homedir" 21 | ) 22 | 23 | func StartKindCluster() error { 24 | return runTestAppMakeTarget("create") 25 | } 26 | 27 | func StartTestApp() error { 28 | return runTestAppMakeTarget("deploy") 29 | } 30 | 31 | func ApplyMaskinportenClient() error { 32 | return runTestAppMakeTarget("client") 33 | } 34 | 35 | func Destroy() error { 36 | return runTestAppMakeTarget("destroy") 37 | } 38 | 39 | func runTestAppMakeTarget(target string) error { 40 | dir, err := GetProjectDir() 41 | if err != nil { 42 | return err 43 | } 44 | dir = path.Join(dir, "test", "app") 45 | 46 | cmd := exec.Command("make", target) 47 | _, err = Run(cmd, dir) 48 | return err 49 | } 50 | 51 | // Run executes the provided command within this context 52 | func Run(cmd *exec.Cmd, dir string) ([]byte, error) { 53 | var err error 54 | if dir == "" { 55 | dir, err = GetProjectDir() 56 | if err != nil { 57 | return nil, err 58 | } 59 | } 60 | 61 | cmd.Dir = dir 62 | if err := os.Chdir(cmd.Dir); err != nil { 63 | fmt.Fprintf(GinkgoWriter, "chdir dir: %s\n", err) 64 | } 65 | 66 | cmd.Env = append(os.Environ(), "GO111MODULE=on") 67 | command := strings.Join(cmd.Args, " ") 68 | fmt.Fprintf(GinkgoWriter, "running: %s\n", command) 69 | output, err := cmd.CombinedOutput() 70 | if err != nil { 71 | return output, fmt.Errorf("%s failed with error: (%v) %s", command, err, string(output)) 72 | } 73 | 74 | return output, nil 75 | } 76 | 77 | // LoadImageToKindCluster loads a local docker image to the kind cluster 78 | func LoadImageToKindClusterWithName(name string) error { 79 | cluster := "operator" 80 | kindOptions := []string{"load", "docker-image", name, "--name", cluster} 81 | cmd := exec.Command("kind", kindOptions...) 82 | _, err := Run(cmd, "") 83 | return err 84 | } 85 | 86 | // GetNonEmptyLines converts given command output string into individual objects 87 | // according to line breakers, and ignores the empty elements in it. 88 | func GetNonEmptyLines(output string) []string { 89 | var res []string 90 | elements := strings.Split(output, "\n") 91 | for _, element := range elements { 92 | if element != "" { 93 | res = append(res, element) 94 | } 95 | } 96 | 97 | return res 98 | } 99 | 100 | // GetProjectDir will return the directory where the project is 101 | func GetProjectDir() (string, error) { 102 | for { 103 | if _, err := os.Stat("go.mod"); err == nil { 104 | if wd, err := os.Getwd(); err != nil { 105 | return "", err 106 | } else { 107 | return wd, nil 108 | } 109 | } 110 | 111 | if err := os.Chdir(".."); err != nil { 112 | return "", err 113 | } 114 | } 115 | } 116 | 117 | var k8sClient *rest.RESTClient 118 | 119 | // GetK8sClient will construct a k8s API client 120 | func GetK8sClient() (*rest.RESTClient, error) { 121 | if k8sClient != nil { 122 | return k8sClient, nil 123 | } 124 | 125 | home := homedir.HomeDir() 126 | if home == "" { 127 | return nil, fmt.Errorf("Could not get KUBECONFIG") 128 | } 129 | kubeconfig := filepath.Join(home, ".kube", "config") 130 | 131 | config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | err = resourcesv1alpha1.AddToScheme(scheme.Scheme) 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | crdConfig := *config 142 | crdConfig.ContentConfig.GroupVersion = &schema.GroupVersion{ 143 | Group: resourcesv1alpha1.GroupVersion.Group, 144 | Version: resourcesv1alpha1.GroupVersion.Version, 145 | } 146 | crdConfig.APIPath = "/apis" 147 | crdConfig.NegotiatedSerializer = serializer.NewCodecFactory(scheme.Scheme) 148 | crdConfig.UserAgent = rest.DefaultKubernetesUserAgent() 149 | 150 | k8sClient, err = rest.UnversionedRESTClientFor(&crdConfig) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | return k8sClient, nil 156 | } 157 | 158 | type deterministicRand struct { 159 | prng *rand.Rand 160 | } 161 | 162 | func NewDeterministicRand() io.Reader { 163 | return &deterministicRand{ 164 | prng: rand.New(rand.NewSource(1337)), 165 | } 166 | } 167 | 168 | func (r *deterministicRand) Read(p []byte) (n int, err error) { 169 | if len(p) == 1 { 170 | // to work around `randutil.MaybeReadByte` 171 | // which is used to enforce non-determinism... 172 | // but here we are just unit/snapshot testing stuff so it's fine 173 | return 1, nil 174 | } 175 | 176 | return r.prng.Read(p) 177 | } 178 | -------------------------------------------------------------------------------- /internal/controller/maskinportenclient_controller_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | "k8s.io/apimachinery/pkg/api/errors" 9 | "k8s.io/apimachinery/pkg/types" 10 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 11 | 12 | corev1 "k8s.io/api/core/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | 15 | resourcesv1alpha1 "github.com/altinn/altinn-k8s-operator/api/v1alpha1" 16 | "github.com/altinn/altinn-k8s-operator/internal" 17 | "github.com/altinn/altinn-k8s-operator/internal/maskinporten" 18 | ) 19 | 20 | var _ = Describe("MaskinportenClient Controller", func() { 21 | Context("When reconciling a resource", func() { 22 | const resourceName = "local-testapp" 23 | const secretName = "local-testapp-deployment-secrets" 24 | 25 | ctx := context.Background() 26 | 27 | typeNamespacedName := types.NamespacedName{ 28 | Name: resourceName, 29 | Namespace: "default", 30 | } 31 | typeNamespacedSecretName := types.NamespacedName{ 32 | Name: secretName, 33 | Namespace: "default", 34 | } 35 | maskinportenclient := &resourcesv1alpha1.MaskinportenClient{} 36 | secret := &corev1.Secret{} 37 | 38 | BeforeEach(func() { 39 | By("creating the custom resource for the Kind MaskinportenClient") 40 | err := k8sClient.Get(ctx, typeNamespacedName, maskinportenclient) 41 | if err != nil && errors.IsNotFound(err) { 42 | resource := &resourcesv1alpha1.MaskinportenClient{ 43 | ObjectMeta: metav1.ObjectMeta{ 44 | Name: resourceName, 45 | Namespace: "default", 46 | Labels: map[string]string{ 47 | "app": "local-testapp-deployment", 48 | }, 49 | }, 50 | Spec: resourcesv1alpha1.MaskinportenClientSpec{ 51 | Scopes: []string{"altinn:resourceregistry/resource.read"}, 52 | }, 53 | } 54 | Expect(k8sClient.Create(ctx, resource)).To(Succeed()) 55 | } 56 | 57 | err = k8sClient.Get(ctx, typeNamespacedSecretName, secret) 58 | if err != nil && errors.IsNotFound(err) { 59 | f := false 60 | resource := &corev1.Secret{ 61 | Immutable: &f, 62 | ObjectMeta: metav1.ObjectMeta{ 63 | Name: secretName, 64 | Namespace: "default", 65 | Labels: map[string]string{ 66 | "app": "local-testapp-deployment", 67 | }, 68 | }, 69 | Type: corev1.SecretTypeOpaque, 70 | } 71 | Expect(k8sClient.Create(ctx, resource)).To(Succeed()) 72 | } 73 | }) 74 | 75 | AfterEach(func() { 76 | { 77 | resource := &resourcesv1alpha1.MaskinportenClient{} 78 | err := k8sClient.Get(ctx, typeNamespacedName, resource) 79 | Expect(err).NotTo(HaveOccurred()) 80 | By("Cleanup the specific resource instance MaskinportenClient") 81 | Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) 82 | } 83 | { 84 | resource := &corev1.Secret{} 85 | err := k8sClient.Get(ctx, typeNamespacedSecretName, resource) 86 | Expect(err).NotTo(HaveOccurred()) 87 | By("Cleanup the specific resource instance secret") 88 | Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) 89 | } 90 | }) 91 | It("should successfully reconcile the resource", func() { 92 | By("Reconciling the created resource") 93 | rt, err := internal.NewRuntime(context.Background(), "") 94 | Expect(err).NotTo(HaveOccurred()) 95 | controllerReconciler := NewMaskinportenClientReconciler( 96 | rt, 97 | k8sClient, 98 | k8sClient.Scheme(), 99 | nil, 100 | ) 101 | 102 | _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ 103 | NamespacedName: typeNamespacedName, 104 | }) 105 | Expect(err).NotTo(HaveOccurred()) 106 | 107 | // The client CRD was reconciled 108 | resource := &resourcesv1alpha1.MaskinportenClient{} 109 | err = k8sClient.Get(ctx, typeNamespacedName, resource) 110 | Expect(err).NotTo(HaveOccurred()) 111 | Expect(resource.Status.State).To(Equal("reconciled")) 112 | Expect(resource.Status.ObservedGeneration).To(Equal(int64(1))) 113 | Expect(resource.Status.Authority).To(Equal(rt.GetConfig().MaskinportenApi.AuthorityUrl)) 114 | 115 | secret := &corev1.Secret{} 116 | err = k8sClient.Get(ctx, typeNamespacedSecretName, secret) 117 | Expect(err).NotTo(HaveOccurred()) 118 | 119 | // Verify the secret state 120 | secretState, err := maskinporten.DeserializeSecretStateContent(secret) 121 | Expect(err).NotTo(HaveOccurred()) 122 | Expect(secretState.ClientId).NotTo(BeEmpty()) 123 | Expect(secretState.Authority).To(Equal(rt.GetConfig().MaskinportenApi.AuthorityUrl)) 124 | Expect(secretState.Jwk).NotTo(BeNil()) 125 | Expect(secretState.Jwks).NotTo(BeNil()) 126 | Expect(secretState.Jwks.Keys).NotTo(BeEmpty()) 127 | Expect(resource.Status.ClientId).To(Equal(secretState.ClientId)) 128 | Expect(secretState.Jwk.KeyID()).To(Equal(secretState.Jwks.Keys[0].KeyID())) 129 | Expect(secretState.Jwk.KeyID()).To(Equal(resource.Status.KeyIds[0])) 130 | }) 131 | }) 132 | }) 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Developer documentation for Altinn 3 Kubernetes operators. 4 | 5 | Here are some important resources: 6 | 7 | * [Team Apps Github board](https://github.com/orgs/Altinn/projects/39/views/2) 8 | * [Altinn Studio docs](https://docs.altinn.studio/) 9 | * Self service API docs: https://docs.digdir.no/docs/idporten/oidc/oidc_api_admin.html 10 | * Self service API dev Swagger UI: https://api.samarbeid.digdir.dev/swagger-ui/index.html?urls.primaryName=External%20OIDC 11 | 12 | ## Reporting Issues 13 | 14 | Open [our Github issue tracker](https://github.com/Altinn/altinn-k8s-operator/issues/new/choose) 15 | and choose an appropriate issue template. 16 | 17 | Feel free to query existing issues before creating a new one. 18 | 19 | ## Contributing changes 20 | 21 | ### Local development 22 | 23 | #### Prerequisites 24 | - go version v1.22.0+ 25 | - docker version 17.03+. 26 | - kubectl version v1.11.3+. 27 | - Access to a Kubernetes v1.11.3+ cluster. 28 | 29 | #### Run tests 30 | 31 | ```sh 32 | make # build 33 | 34 | make test # runs local tests (doesn't need k8s etc) 35 | 36 | make test-e2e # runs e2e tests, requires kind 37 | ``` 38 | 39 | We use [go-snaps](https://github.com/gkampitakis/go-snaps) for snapshot tests. 40 | [Update snapshots](https://github.com/gkampitakis/go-snaps?tab=readme-ov-file#update-snapshots) by running 41 | 42 | ```sh 43 | UPDATE_SNAPS=true make test 44 | ``` 45 | 46 | #### To Deploy on the cluster 47 | **Build and push your image to the location specified by `IMG`:** 48 | 49 | ```sh 50 | make docker-build docker-push IMG=/altinn-k8s-operator:tag 51 | ``` 52 | 53 | **NOTE:** This image ought to be published in the personal registry you specified. 54 | And it is required to have access to pull the image from the working environment. 55 | Make sure you have the proper permission to the registry if the above commands don’t work. 56 | 57 | **Install the CRDs into the cluster:** 58 | 59 | ```sh 60 | make install 61 | ``` 62 | 63 | **Deploy the Manager to the cluster with the image specified by `IMG`:** 64 | 65 | ```sh 66 | make deploy IMG=/altinn-k8s-operator:tag 67 | ``` 68 | 69 | > **NOTE**: If you encounter RBAC errors, you may need to grant yourself cluster-admin 70 | privileges or be logged in as admin. 71 | 72 | **Create instances of your solution** 73 | You can apply the samples (examples) from the config/sample: 74 | 75 | ```sh 76 | kubectl apply -k config/samples/ 77 | ``` 78 | 79 | >**NOTE**: Ensure that the samples has default values to test it out. 80 | 81 | #### To Uninstall 82 | **Delete the instances (CRs) from the cluster:** 83 | 84 | ```sh 85 | kubectl delete -k config/samples/ 86 | ``` 87 | 88 | **Delete the APIs(CRDs) from the cluster:** 89 | 90 | ```sh 91 | make uninstall 92 | ``` 93 | 94 | **UnDeploy the controller from the cluster:** 95 | 96 | ```sh 97 | make undeploy 98 | ``` 99 | 100 | ## Project Distribution 101 | 102 | Following are the steps to build the installer and distribute this project to users. 103 | 104 | 1. Build the installer for the image built and published in the registry: 105 | 106 | ```sh 107 | make build-installer IMG=/altinn-k8s-operator:tag 108 | ``` 109 | 110 | NOTE: The makefile target mentioned above generates an 'install.yaml' 111 | file in the dist directory. This file contains all the resources built 112 | with Kustomize, which are necessary to install this project without 113 | its dependencies. 114 | 115 | 2. Using the installer 116 | 117 | Users can just run kubectl apply -f to install the project, i.e.: 118 | 119 | ```sh 120 | kubectl apply -f https://raw.githubusercontent.com//altinn-k8s-operator//dist/install.yaml 121 | ``` 122 | 123 | ### Contributing 124 | 125 | // TODO: doc 126 | 127 | ### JSON schema -> Go structs 128 | 129 | The Swagger UI is at: https://api.samarbeid.digdir.dev/swagger-ui/index.html#/ 130 | OpenAPI spec at: https://api.samarbeid.digdir.dev/v3/api-docs/altinn-admin 131 | 132 | Download to `schemas/spec.json`, tell AI to write the models, update the client and fakes. 133 | 134 | ### Upgrading 135 | 136 | If kubebuilder bumps major version, in some cases there is not much to do. Still, it might be worth it to 137 | 138 | * Upgrade kubebuilder CLI 139 | * Scaffold a new project 140 | * Generate some CRD that we use 141 | * Inspect diff with this repo 142 | * Case-insensitive search for `version` to make sure hardcoded versions are up to date 143 | * Run all builds, tests, lints etc.. 144 | 145 | That way we don't get stuck on old versions of the scaffold forever.. 146 | Example for v3 -> v4 upgrade of CLI: 147 | 148 | ```sh 149 | mkdir altinn-k8s-operator2 150 | cd altinn-k8s-operator2 151 | kubebuilder init --plugins go/v4 --domain altinn.studio --owner "Altinn" --repo "github.com/Altinn/altinn-k8s-operator" --project-name "altinn-k8s-operator" 152 | kubebuilder create api --group resources --version v1alpha1 --kind MaskinportenClient 153 | make manifests 154 | ``` 155 | -------------------------------------------------------------------------------- /local.env: -------------------------------------------------------------------------------- 1 | maskinporten_api.authority_url=http://localhost:8050 2 | maskinporten_api.self_service_url=http://localhost:8051 3 | maskinporten_api.client_id=altinn_apps_supplier_client 4 | maskinporten_api.jwk='{"use":"sig","kty":"RSA","kid":"b9263e99-b2e4-4e16-b7eb-cc878a99d6e7.0","alg":"RS512","n":"uCI86gU5_M9-xiTN7qKUv-ZmAgXwWthij0FlbGtfHaXD4Yp0he3SmLxxqS-f3QOg3mweDYCaABmqs61BBRAADPpmd9d1-lxcFLnpz6DvlgCseBvrTx22YiXpFCWZCUlL82Kcy5vgD3POV9X0MBfmI1sc7BdqXp59zQ4jDy1zXQP7Vj4w1mCm9_ww90GzalxDcL7YzeAgu-9gci0g4MPSghBRWlYXvQPGUUmShC8MBDqKRbNsBeoS8hOvG1XfbRt-74vSertKbdGzXXmYFzmTEH8oP4WnRNWV2k5zIO8Ia-aCA2O7EO2VEtOKaOWaeHz1-nkMcQxypwOwsxEMrwTfwLt8GVtWhX6hEQZPPrA4VO3EunhZsXwZ1_iT48CyCtx9vDUmMpTAP8zuB_D8tEnsu94N1O39HNT49INxG949t6CpA6TZjVn2FZ9MJPQLwB4uadaMryR-CLYjNyn2DivEN4gcUzvlyagCyLvo78VQBDrpCVXdol6_haXWU8XN0DQKwXyBrtV4Q6QrT60D4EuXum1ZuavZUwInPff0t79lsRMpkJN_M0w6Kksrjv4xQS_Tl9JiHyHD-9PIoroSQElDjFwwKBQ9hp1KBOiS_SQdm7jngoJXdb0KFmCYj-AKLEHPq4WSSmAplf-ZcycuP4U8gmIusKcLAZB-or8SKodWa4M","e":"AQAB","d":"cZTdElYLAQFVaBBH3032h7Etd04Gh2M22Ls0Pv60e2tHOxbW7c5Xu9NyITS5XfHhB5KVryqG1E0A2TikBOVrwpWrI32KztauDjLoISVa5KKhwK0oJ3Nij4RnFABlOC84ZHeN1KLgQWfj_paBvDDhyylm29NNz_PgEd8IjVIx-Ux9eyN9qJ-SHyI3ai3i6FblWuS-g7AfQQ5V5dgkkcD5VzWNmTXGCtgLOxUxBcynkuwxYvFcTwGmkiDGQQxld74gPM95FC_3p2pVQ_G_eYQQTXrCbvyYw4MknrcJmWUZQsW7qS-ZssV60VQf6rjG4k_iw5BrtkhBaPiDxNFdi5BsHD9QD7JvZFTNZGn1UXW_g_2AGBzP_i0AEjtTusnU6SgixlK9pOYAdm-H83PlByyzW26BKQ7ixwMAxVn-XctQjEwnn4q3tl9UV5FWw7Ns6xIyDwSK9IJg3GsTcTtulT1mKwDpuwckbYznkqSIqhA4xLLne5HQE3O33JQ3t-k9KIIlCpeVjDctEYQegggPDM2yTe2fFdReGt7xXCnec8Is6i9cJe8QlUUYWmMQsvkUXLUam6iJge6UbKqw6AYK70YD-gF9H5Sl7s1MdmoEbDyOHn5XCrPA9Yky8fRYCX35-I8aPBgs5zWOThO0wWeDExYqQXg8Ce1fvrY68Q_1ED96xhE","p":"wOhGgCK0d7n19GsEXARyZj5GYfXB7kKBSxMkU4pNZ_j9FASunbByfX_KP0DNeT1vXw8EG9qWLXbc49GAvFs6Gt9ZRKplJP5bWtAmd9J_8PVuOLYqKAksHSXB83USGhV8Iup6xfkI_IEUGQAyHPzPmzs10ZivyopfztkHfXUiMZM0xLoupPV_iQjOTwpr-COZnBrggInJkoH3iwGZwnSNd0axMeDb0xDJSo9vFtzYi5uj737ygZZWSCPEjfxjn8fl7JVAxyARY7VkfGIApsX5FiXwYuJCF1ANkCBr9XqLIUqAXrrvHqS6ngQ0WwgDQw87gatMlVGKt3ykZujZeAE37w","q":"9FtebDYKbI4C-Oep7VsSiVK4CvDAwAtLtqw2tZBIykZERKgiosrGOwMTQSNX8RQpt_JX5N4cFCngo7DU9psZefWDmwsxosUMfdE2CILer6qH10ZB08e2PEHIwlcvfVO-Rq4HAxf5afUjat7R_flT_BHj7CaeiNazwH6Lb_5-N0VNcGKZWbpF2kOPcnWxmFhHrKZJrUfQ4tVYSczR99IrrMRHM8e6qyP8opCCriajbHVZLLR5ncuFqQreQT4kMEhX9140nEzq5sFf09ZdozVyDKovNApwdtmdKlQO9JUipXk6_kGBt4xbZm2vY36VXMccFBa1471QW8o7xvR9OD9RrQ","dp":"kvuoVBOdbCg-FljAPpiIzgyfNh66AB-eQiS4pgqYBiO6OVmD7tS1t5f58w4eQUWlKUnYuJxplwSdM9y6eUoNUNJjQyWN4Y0I8H3vAZdbMq7ep8ls_4pVmXPefvDxtPwv1K7Skyu4RCTZul7i0CF00fNgg24Sa4HZlFLbGSV5w0pFh6vQxJHl9fTGtYTcVXpSnZYA_w99jesHQVwb2wVRkNNFShrpg72jkfMOEt59BIq3c1FH16ND5L2UExd-lQ0LzKLAc7ikZ1Ob2AYYNvpbWxvXOJDrCLZPT0TU3XrcraYFf6hxb-jV5HaRqdbGHX9quNdbh95UkpAe9-ZtZLmQ8w","dq":"2DTb5_0s3f4NTTSVWumBDjY9l5iLw6B6_oeD5MRkU202zFTESKwIF4DSEYl3L1z6yMJJ2LxZtdGT7OHynLyBHzMHnjCaW33kXpK1L3S0GlRV2zlT11HWwZwnSSUhZM-rBRjIJYmZ6pG3I8FBpmlsURV3SKSnE0Z9R23wbEiOXtMYAL-NFiJF2ih7DPhsCfLagD2l5QctIPdKJgpvIco5UKVepscrOHAgAarBpduUL8vo-jA5h0_j1L1ECBA2ru3jv4EAJee81C33XxVGRrlsTx5po6808UP81s4HaYtnW2hXtU46uzAaUxfr3qnK-ItIIdIyX-5K4tyeZZxAC3ujBQ","qi":"Tbb8K06JAgEQXMAI1zbn_yK49IHcaSLpmx3VAyCa8upxhKGPkNdqhSOfiFn81RIvREVmK_JDEN9YRGjssnvWr5gfMCztJgARdt3pARR01xC8WS7seYY-JsxeWdeGorVCnHuIra8iAUVjx7XVYsXd7pRqmFHCLBrQYm957aS6RNUPWEgzGv1oIb7UiiXccby6ng7L9C2Jq4u9blW3imIVYu3MA5Yl8MqRAs3DN03eMcgbKglP4MQuanYh2UmI9HT15pWGwkNYjGGZWoGoMWqMyhHGRms7Lg5qKeLstwS8U9MAc62pccsCi86dYIkc61F6obFM3UXm2L_ui58YqdY5VQ","x5c":["MIIFEzCCAvugAwIBAgIQet6XArvCfIqWf0TQo6NWhDANBgkqhkiG9w0BAQ0FADAqMQ4wDAYDVQQKEwVsb2NhbDEYMBYGA1UEAxMPYWx0aW5uLW9wZXJhdG9yMB4XDTI1MDkxNzEyMTUxMVoXDTI2MDkxNzEyMTUxMFowKjEOMAwGA1UEChMFbG9jYWwxGDAWBgNVBAMTD2FsdGlubi1vcGVyYXRvcjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALgiPOoFOfzPfsYkze6ilL/mZgIF8FrYYo9BZWxrXx2lw+GKdIXt0pi8cakvn90DoN5sHg2AmgAZqrOtQQUQAAz6ZnfXdfpcXBS56c+g75YArHgb608dtmIl6RQlmQlJS/NinMub4A9zzlfV9DAX5iNbHOwXal6efc0OIw8tc10D+1Y+MNZgpvf8MPdBs2pcQ3C+2M3gILvvYHItIODD0oIQUVpWF70DxlFJkoQvDAQ6ikWzbAXqEvITrxtV320bfu+L0nq7Sm3Rs115mBc5kxB/KD+Fp0TVldpOcyDvCGvmggNjuxDtlRLTimjlmnh89fp5DHEMcqcDsLMRDK8E38C7fBlbVoV+oREGTz6wOFTtxLp4WbF8Gdf4k+PAsgrcfbw1JjKUwD/M7gfw/LRJ7LveDdTt/RzU+PSDcRvePbegqQOk2Y1Z9hWfTCT0C8AeLmnWjK8kfgi2Izcp9g4rxDeIHFM75cmoAsi76O/FUAQ66QlV3aJev4Wl1lPFzdA0CsF8ga7VeEOkK0+tA+BLl7ptWbmr2VMCJz339Le/ZbETKZCTfzNMOipLK47+MUEv05fSYh8hw/vTyKK6EkBJQ4xcMCgUPYadSgTokv0kHZu454KCV3W9ChZgmI/gCixBz6uFkkpgKZX/mXMnLj+FPIJiLrCnCwGQfqK/EiqHVmuDAgMBAAGjNTAzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBDQUAA4ICAQA1Ufsrvq6VCCGwHDnqIJgfpTl1LahSpGPIgNzRBCkoD32SCXI30c+eMjshbx2uNYLktGT8chDO6I05My6oNexMc9BAKXSrQLqMgb3FcVU76clqkIRAYeE815Wawgg1dGK5B9tLxv2yGgm3xsx73CsBraVxmnRVry/ctD6LukYs06ZKS3IZYXggKPyJ/C3FXUjaz2GZyFXJKKAKYPj2SGUQ4tUO8GjO31qCxBWLQZT8JPb9x11Fup5BD7xSTrv0OVfsyfN/mFitrFx7wLIIYJAU4yyC4Y+vcSt5Uo7NhrAlU0sUl6EhWFUQfSYc/Pau47OZc6slT+2ejRbNT5mmacMPt0IwEFiNJzW/RKZdoxxORkhUUALusdNHLuqop5E2S0VPR1RSacqBODMXjB5T7UkfG3tbkrB/iaMSGL45K3U9vsO0MzVJKREg7Eumzfx2mn+5s3tKtxLuu+w+lDSPDHgFdef1KAFDvOLWuJr8CZNg4T957tW5OD2xfu2BoDTKhi2uLb/1oXRdQbWWRkkLJfRRx+7exBd1S8KvrsfnPqKN/kjO+x0au71pzVP/4t4HvNfqnb8FrWv0psxRtOU8q3JXwae1AVALq07FjQ1bm587Ud4tz1xvi4V0x2fi3MaEk/RCm344bYC9GnjUau/tbAiTKhiv/h7C09Phh0xkUmi2CQ=="]}' 5 | maskinporten_api.scope=idporten:dcr.altinn 6 | controller.requeue_after=24h 7 | -------------------------------------------------------------------------------- /internal/crypto/crypto_test.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "testing" 7 | "time" 8 | 9 | "github.com/altinn/altinn-k8s-operator/internal/operatorcontext" 10 | "github.com/altinn/altinn-k8s-operator/test/utils" 11 | "github.com/gkampitakis/go-snaps/snaps" 12 | "github.com/jonboulle/clockwork" 13 | . "github.com/onsi/gomega" 14 | ) 15 | 16 | const appId string = "app1" 17 | 18 | func TestCreateJwks(t *testing.T) { 19 | g := NewWithT(t) 20 | 21 | // We use fixed inputs and make JWKS generation deterministic 22 | // to enable snapshot testing. It's important that we have control 23 | // over the outputs of this package and notice any changes across Go versions and library updates. 24 | 25 | jwks, _, _, err := createTestJwks() 26 | g.Expect(err).NotTo(HaveOccurred()) 27 | g.Expect(jwks).NotTo(BeNil()) 28 | 29 | json, err := json.Marshal(jwks) 30 | g.Expect(err).NotTo(HaveOccurred()) 31 | g.Expect(json).NotTo(BeNil()) 32 | snaps.MatchJSON(t, json) 33 | } 34 | 35 | func TestRotateJwks(t *testing.T) { 36 | g := NewWithT(t) 37 | 38 | jwks, service, clock, err := createTestJwks() 39 | g.Expect(err).NotTo(HaveOccurred()) 40 | g.Expect(jwks).NotTo(BeNil()) 41 | 42 | // We have only just created the cert 43 | clock.Advance(time.Hour * 1) 44 | newJwks, err := service.RotateIfNeeded(appId, getNotAfter(clock), jwks) 45 | g.Expect(err).NotTo(HaveOccurred()) 46 | g.Expect(newJwks).To(BeNil()) 47 | 48 | // This should be before the rotation threshold 49 | clock.Advance(time.Hour * 24 * 18) 50 | newJwks, err = service.RotateIfNeeded(appId, getNotAfter(clock), jwks) 51 | g.Expect(err).NotTo(HaveOccurred()) 52 | g.Expect(newJwks).To(BeNil()) 53 | 54 | // Now we've advanced past the treshold and should have rotated 55 | clock.Advance(time.Hour * 24 * 7) 56 | newJwks, err = service.RotateIfNeeded(appId, getNotAfter(clock), jwks) 57 | g.Expect(err).NotTo(HaveOccurred()) 58 | g.Expect(newJwks).NotTo(BeNil()) 59 | g.Expect(newJwks.Keys).To(HaveLen(2)) 60 | oldCert := newJwks.Keys[1].Certificates()[0] 61 | newCert := newJwks.Keys[0].Certificates()[0] 62 | g.Expect(newCert.NotAfter.After(oldCert.NotAfter)).To(BeTrue()) 63 | 64 | // We should rotate again 65 | clock.Advance(time.Hour * 24 * 25) 66 | newerJwks, err := service.RotateIfNeeded(appId, getNotAfter(clock), newJwks) 67 | g.Expect(err).NotTo(HaveOccurred()) 68 | g.Expect(newerJwks).NotTo(BeNil()) 69 | g.Expect(newerJwks.Keys).To(HaveLen(2)) 70 | newerCert := newerJwks.Keys[0].Certificates()[0] 71 | g.Expect(newerJwks.Keys[1].Certificates()[0]).To(BeIdenticalTo(newCert)) 72 | g.Expect(newerCert.NotAfter.After(newCert.NotAfter)).To(BeTrue()) 73 | 74 | // Serialize the new JWKS 75 | newJson, err := json.Marshal(newJwks) 76 | g.Expect(err).NotTo(HaveOccurred()) 77 | g.Expect(newJson).NotTo(BeNil()) 78 | snaps.MatchJSON(t, newJson) 79 | 80 | newerJson, err := json.Marshal(newerJwks) 81 | g.Expect(err).NotTo(HaveOccurred()) 82 | g.Expect(newerJson).NotTo(BeNil()) 83 | snaps.MatchJSON(t, newerJson) 84 | } 85 | 86 | func TestGenerateCertSerialNumber(t *testing.T) { 87 | g := NewWithT(t) 88 | 89 | service, _ := createService() 90 | 91 | serial, err := service.generateCertSerialNumber() 92 | g.Expect(err).NotTo(HaveOccurred()) 93 | g.Expect(serial.Sign()).ToNot(BeIdenticalTo(-1)) 94 | g.Expect(serial.Bytes()).To(HaveLen(16)) 95 | 96 | snaps.MatchSnapshot(t, serial.String()) 97 | } 98 | 99 | func TestPublicJwksConversion(t *testing.T) { 100 | g := NewWithT(t) 101 | 102 | jwks, _, _, err := createTestJwks() 103 | g.Expect(err).NotTo(HaveOccurred()) 104 | g.Expect(jwks).NotTo(BeNil()) 105 | g.Expect(jwks.Keys[0].Certificates()).NotTo(BeNil()) 106 | 107 | publicJwks, err := jwks.ToPublic() 108 | g.Expect(err).NotTo(HaveOccurred()) 109 | g.Expect(publicJwks).NotTo(BeNil()) 110 | // Certificates is marshalled as "x5c", which Maskinporten doens't want 111 | g.Expect(publicJwks.Keys[0].Certificates()).To(BeNil()) 112 | g.Expect(jwks.Keys[0].Certificates()).NotTo(BeNil()) 113 | 114 | jsonPayload, err := json.Marshal(publicJwks) 115 | g.Expect(err).NotTo(HaveOccurred()) 116 | g.Expect(jsonPayload).NotTo(BeNil()) 117 | snaps.MatchJSON(t, jsonPayload) 118 | 119 | jwk := jwks.Keys[0] 120 | publicJwk := jwk.Public() 121 | g.Expect(publicJwk.Certificates()).To(BeNil()) 122 | g.Expect(jwk.Certificates()).NotTo(BeNil()) 123 | 124 | jsonPayload, err = json.Marshal(publicJwk) 125 | g.Expect(err).NotTo(HaveOccurred()) 126 | g.Expect(jsonPayload).NotTo(BeNil()) 127 | snaps.MatchJSON(t, jsonPayload) 128 | 129 | // TODO: assert that private key fields are not present in JWK 130 | } 131 | 132 | func createService() (*CryptoService, clockwork.FakeClock) { 133 | operatorContext := operatorcontext.DiscoverOrDie(context.Background()) 134 | clock := clockwork.NewFakeClockAt(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) 135 | random := utils.NewDeterministicRand() 136 | service := NewDefaultService(operatorContext, clock, random) 137 | return service, clock 138 | } 139 | 140 | func getNotAfter(clock clockwork.Clock) time.Time { 141 | return clock.Now().UTC().Add(time.Hour * 24 * 30) 142 | } 143 | 144 | func createTestJwks() (*Jwks, *CryptoService, clockwork.FakeClock, error) { 145 | service, clock := createService() 146 | 147 | jwks, err := service.CreateJwks(appId, getNotAfter(clock)) 148 | if err != nil { 149 | return nil, nil, nil, err 150 | } 151 | 152 | return jwks, service, clock, nil 153 | } 154 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: altinn-k8s-operator-system 3 | 4 | # Value of this field is prepended to the 5 | # names of all resources, e.g. a deployment named 6 | # "wordpress" becomes "alices-wordpress". 7 | # Note that it should also match with the prefix (text before '-') of the namespace 8 | # field above. 9 | namePrefix: altinn-k8s-operator- 10 | 11 | # Labels to add to all resources and selectors. 12 | #labels: 13 | #- includeSelectors: true 14 | # pairs: 15 | # someName: someValue 16 | 17 | resources: 18 | - ../crd 19 | - ../rbac 20 | - ../manager 21 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 22 | # crd/kustomization.yaml 23 | #- ../webhook 24 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. 25 | #- ../certmanager 26 | # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. 27 | # - ../prometheus 28 | # [METRICS] To enable the controller manager metrics service, uncomment the following line. 29 | #- metrics_service.yaml 30 | 31 | # Uncomment the patches line if you enable Metrics, and/or are using webhooks and cert-manager 32 | #patches: 33 | # [METRICS] The following patch will enable the metrics endpoint. Ensure that you also protect this endpoint. 34 | # More info: https://book.kubebuilder.io/reference/metrics 35 | # If you want to expose the metric endpoint of your controller-manager uncomment the following line. 36 | #- path: manager_metrics_patch.yaml 37 | # target: 38 | # kind: Deployment 39 | 40 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 41 | # crd/kustomization.yaml 42 | #- path: manager_webhook_patch.yaml 43 | 44 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 45 | # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. 46 | # 'CERTMANAGER' needs to be enabled to use ca injection 47 | #- path: webhookcainjection_patch.yaml 48 | 49 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 50 | # Uncomment the following replacements to add the cert-manager CA injection annotations 51 | #replacements: 52 | # - source: # Add cert-manager annotation to ValidatingWebhookConfiguration, MutatingWebhookConfiguration and CRDs 53 | # kind: Certificate 54 | # group: cert-manager.io 55 | # version: v1 56 | # name: serving-cert # this name should match the one in certificate.yaml 57 | # fieldPath: .metadata.namespace # namespace of the certificate CR 58 | # targets: 59 | # - select: 60 | # kind: ValidatingWebhookConfiguration 61 | # fieldPaths: 62 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 63 | # options: 64 | # delimiter: '/' 65 | # index: 0 66 | # create: true 67 | # - select: 68 | # kind: MutatingWebhookConfiguration 69 | # fieldPaths: 70 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 71 | # options: 72 | # delimiter: '/' 73 | # index: 0 74 | # create: true 75 | # - select: 76 | # kind: CustomResourceDefinition 77 | # fieldPaths: 78 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 79 | # options: 80 | # delimiter: '/' 81 | # index: 0 82 | # create: true 83 | # - source: 84 | # kind: Certificate 85 | # group: cert-manager.io 86 | # version: v1 87 | # name: serving-cert # this name should match the one in certificate.yaml 88 | # fieldPath: .metadata.name 89 | # targets: 90 | # - select: 91 | # kind: ValidatingWebhookConfiguration 92 | # fieldPaths: 93 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 94 | # options: 95 | # delimiter: '/' 96 | # index: 1 97 | # create: true 98 | # - select: 99 | # kind: MutatingWebhookConfiguration 100 | # fieldPaths: 101 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 102 | # options: 103 | # delimiter: '/' 104 | # index: 1 105 | # create: true 106 | # - select: 107 | # kind: CustomResourceDefinition 108 | # fieldPaths: 109 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 110 | # options: 111 | # delimiter: '/' 112 | # index: 1 113 | # create: true 114 | # - source: # Add cert-manager annotation to the webhook Service 115 | # kind: Service 116 | # version: v1 117 | # name: webhook-service 118 | # fieldPath: .metadata.name # namespace of the service 119 | # targets: 120 | # - select: 121 | # kind: Certificate 122 | # group: cert-manager.io 123 | # version: v1 124 | # fieldPaths: 125 | # - .spec.dnsNames.0 126 | # - .spec.dnsNames.1 127 | # options: 128 | # delimiter: '.' 129 | # index: 0 130 | # create: true 131 | # - source: 132 | # kind: Service 133 | # version: v1 134 | # name: webhook-service 135 | # fieldPath: .metadata.namespace # namespace of the service 136 | # targets: 137 | # - select: 138 | # kind: Certificate 139 | # group: cert-manager.io 140 | # version: v1 141 | # fieldPaths: 142 | # - .spec.dnsNames.0 143 | # - .spec.dnsNames.1 144 | # options: 145 | # delimiter: '.' 146 | # index: 1 147 | # create: true 148 | -------------------------------------------------------------------------------- /internal/maskinporten/client_response.go: -------------------------------------------------------------------------------- 1 | package maskinporten 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/altinn/altinn-k8s-operator/internal/crypto" 7 | ) 8 | 9 | // ClientResponse represents the response when retrieving or creating a client 10 | type ClientResponse struct { 11 | ClientId string `json:"client_id"` 12 | 13 | // Navn på klient, blir vist ved innlogging 14 | ClientName *string `json:"client_name,omitempty"` 15 | 16 | // Klienten sitt organisasjonsnummer. 17 | ClientOrgno *string `json:"client_orgno,omitempty"` 18 | 19 | // Leverandøren sitt organisasjonsnummer. 20 | SupplierOrgno *string `json:"supplier_orgno,omitempty"` 21 | 22 | // Beskrivelse av klienten, ikke synlig for innbyggere. 23 | Description *string `json:"description,omitempty"` 24 | 25 | Active *bool `json:"active,omitempty"` 26 | 27 | Created *time.Time `json:"created,omitempty"` 28 | 29 | LastUpdated *time.Time `json:"last_updated,omitempty"` 30 | 31 | // Applikasjonstype 32 | ApplicationType *ApplicationType `json:"application_type,omitempty"` 33 | 34 | // Integrasjonstype 35 | IntegrationType *IntegrationType `json:"integration_type,omitempty"` 36 | 37 | // Liste over scopes som klienten kan forespørre. 38 | Scopes []string `json:"scopes,omitempty"` 39 | 40 | // Tillatte Grant Types for klient. 41 | GrantTypes []GrantType `json:"grant_types,omitempty"` 42 | 43 | // Autentiseringsmetode for klient. 44 | TokenEndpointAuthMethod *TokenEndpointAuthMethod `json:"token_endpoint_auth_method,omitempty"` 45 | 46 | // Levetid i sekunder for utstedt refresh_token 47 | RefreshTokenLifetime *int64 `json:"refresh_token_lifetime,omitempty"` 48 | 49 | // Ved REUSE kan refresh_token benyttes flere ganger. Ved ONETIME kan refresh_token kun benyttes en gang. 50 | RefreshTokenUsage *RefreshTokenUsage `json:"refresh_token_usage,omitempty"` 51 | 52 | // Levetid i sekunder for utstedt access_token 53 | AccessTokenLifetime *int64 `json:"access_token_lifetime,omitempty"` 54 | 55 | // Levetid for registrert autorisasjon i sekunder. I en OpenID Connect sammenheng vil dette være tilgangen til userinfo-endepunktet. 56 | AuthorizationLifetime *int64 `json:"authorization_lifetime,omitempty"` 57 | 58 | OnBehalfOf []ClientOnBehalfOf `json:"onbehalfof,omitempty"` 59 | 60 | // Secret kan ikke settes direkte. Secret blir generert ved behov og dette feltet er kun for retur av secret 61 | ClientSecret *string `json:"client_secret,omitempty"` 62 | 63 | Jwks *crypto.Jwks `json:"jwks,omitempty"` 64 | 65 | // Uri til JWKS om satt. Kan kun leses ut, ikke settes. 66 | JwksUri *string `json:"jwks_uri,omitempty"` 67 | 68 | LogoUri *string `json:"logo_uri,omitempty"` 69 | 70 | // Liste over gyldige url'er som vi kan redirecte tilbake til etter vellykket autorisasjonsforespørsel 71 | RedirectUris []string `json:"redirect_uris,omitempty"` 72 | 73 | // Liste over url'er som vi redirecter til etter fullført utlogging 74 | PostLogoutRedirectUris []string `json:"post_logout_redirect_uris,omitempty"` 75 | 76 | // Flagg som bestemmer om parameterne for issuer og sesjons-id skal sendes med frontchannel_logout_uri 77 | FrontchannelLogoutSessionRequired *bool `json:"frontchannel_logout_session_required,omitempty"` 78 | 79 | // URL som vi sender request til ved utlogging trigget av annen klient i samme sesjon 80 | FrontchannelLogoutUri *string `json:"frontchannel_logout_uri,omitempty"` 81 | 82 | // Flagg for å disable sso. Dette vil gjøre at brukeren må logge inn på nytt for din klient. Dette er kun relevant for OpenID Connect. 83 | SsoDisabled *bool `json:"sso_disabled,omitempty"` 84 | 85 | // Code challenge method for PKCE. Gyldige verdier er none eller S256. Dette er kun relevant for OpenID Connect. 86 | CodeChallengeMethod *CodeChallengeMethod `json:"code_challenge_method,omitempty"` 87 | } 88 | 89 | // ClientOnBehalfOf represents on-behalf-of client information 90 | type ClientOnBehalfOf struct { 91 | // ID for onbehalfof klient. Fulle klient navn blir :: 92 | OnBehalfOf *string `json:"onbehalfof,omitempty"` 93 | 94 | // Navn på klient, blir vist ved innlogging 95 | Name *string `json:"name,omitempty"` 96 | 97 | // Klienten sitt organisasjonsnummer 98 | Orgno *string `json:"orgno,omitempty"` 99 | 100 | // Organisasjonsnavnet som tilhører organisasjonsnummeret 101 | OrganizationName *string `json:"organization_name,omitempty"` 102 | 103 | // Beskrivelse av klienten, ikke synlig for innbyggere, men blir lagret i Digdir sine støttesystemer 104 | Description *string `json:"description,omitempty"` 105 | 106 | LogoUri *string `json:"logo_uri,omitempty"` 107 | 108 | Created *time.Time `json:"created,omitempty"` 109 | 110 | LastUpdated *time.Time `json:"last_updated,omitempty"` 111 | } 112 | 113 | // OidcJwksRequestResponse represents JWKS request/response 114 | type OidcJwksRequestResponse = crypto.Jwks 115 | 116 | // ApiError represents a single specific error 117 | type ApiError struct { 118 | ErrorMessage *string `json:"errorMessage,omitempty"` 119 | IsFieldError *bool `json:"isFieldError,omitempty"` 120 | ObjectName *string `json:"objectName,omitempty"` 121 | FieldIdentifier *string `json:"fieldIdentifier,omitempty"` 122 | } 123 | 124 | // ApiErrorResponse represents a response containing information about errors from the API 125 | type ApiErrorResponse struct { 126 | Status *int32 `json:"status,omitempty"` 127 | Timestamp *time.Time `json:"timestamp,omitempty"` 128 | CorrelationId *string `json:"correlation_id,omitempty"` 129 | Errors []ApiError `json:"errors,omitempty"` 130 | Error *string `json:"error,omitempty"` 131 | ErrorDescription *string `json:"error_description,omitempty"` 132 | } 133 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "flag" 7 | "os" 8 | "time" 9 | 10 | "github.com/go-errors/errors" 11 | 12 | // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) 13 | // to ensure that exec-entrypoint and run can make use of them. 14 | 15 | "go.opentelemetry.io/otel" 16 | _ "k8s.io/client-go/plugin/pkg/client/auth" 17 | 18 | "k8s.io/apimachinery/pkg/runtime" 19 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 20 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 21 | ctrl "sigs.k8s.io/controller-runtime" 22 | "sigs.k8s.io/controller-runtime/pkg/cache" 23 | "sigs.k8s.io/controller-runtime/pkg/healthz" 24 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 25 | metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 26 | "sigs.k8s.io/controller-runtime/pkg/webhook" 27 | 28 | resourcesv1alpha1 "github.com/altinn/altinn-k8s-operator/api/v1alpha1" 29 | "github.com/altinn/altinn-k8s-operator/internal" 30 | "github.com/altinn/altinn-k8s-operator/internal/controller" 31 | "github.com/altinn/altinn-k8s-operator/internal/telemetry" 32 | // +kubebuilder:scaffold:imports 33 | ) 34 | 35 | var ( 36 | scheme = runtime.NewScheme() 37 | setupLog = ctrl.Log.WithName("setup") 38 | ) 39 | 40 | func init() { 41 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 42 | 43 | utilruntime.Must(resourcesv1alpha1.AddToScheme(scheme)) 44 | // +kubebuilder:scaffold:scheme 45 | } 46 | 47 | func main() { 48 | var metricsAddr string 49 | var enableLeaderElection bool 50 | var probeAddr string 51 | var secureMetrics bool 52 | var enableHTTP2 bool 53 | flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metric endpoint binds to. "+ 54 | "Use the port :8080. If not set, it will be 0 in order to disable the metrics server") 55 | flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") 56 | flag.BoolVar(&enableLeaderElection, "leader-elect", false, 57 | "Enable leader election for controller manager. "+ 58 | "Enabling this will ensure there is only one active controller manager.") 59 | flag.BoolVar(&secureMetrics, "metrics-secure", false, 60 | "If set the metrics endpoint is served securely") 61 | flag.BoolVar(&enableHTTP2, "enable-http2", false, 62 | "If set, HTTP/2 will be enabled for the metrics and webhook servers") 63 | opts := zap.Options{ 64 | Development: true, 65 | } 66 | opts.BindFlags(flag.CommandLine) 67 | flag.Parse() 68 | 69 | ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) 70 | 71 | ctx := ctrl.SetupSignalHandler() 72 | 73 | // Set up OpenTelemetry. 74 | otelShutdown, err := telemetry.ConfigureOTel(ctx) 75 | if err != nil { 76 | setupLog.Error(err, "unable to configure OTel") 77 | os.Exit(1) 78 | } 79 | 80 | ctx, span := otel.Tracer(telemetry.ServiceName).Start(ctx, "Main") 81 | 82 | rt, err := internal.NewRuntime(ctx, "") 83 | if err != nil { 84 | setupLog.Error(err, "unable to initialize runtime") 85 | span.End() 86 | os.Exit(1) 87 | } 88 | 89 | // Handle shutdown properly so nothing leaks. 90 | defer func() { 91 | err = errors.Join(err, otelShutdown(context.Background())) 92 | }() 93 | 94 | // if the enable-http2 flag is false (the default), http/2 should be disabled 95 | // due to its vulnerabilities. More specifically, disabling http/2 will 96 | // prevent from being vulnerable to the HTTP/2 Stream Cancelation and 97 | // Rapid Reset CVEs. For more information see: 98 | // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 99 | // - https://github.com/advisories/GHSA-4374-p667-p6c8 100 | disableHTTP2 := func(c *tls.Config) { 101 | setupLog.Info("disabling http/2") 102 | c.NextProtos = []string{"http/1.1"} 103 | } 104 | 105 | tlsOpts := []func(*tls.Config){} 106 | if !enableHTTP2 { 107 | tlsOpts = append(tlsOpts, disableHTTP2) 108 | } 109 | 110 | webhookServer := webhook.NewServer(webhook.Options{ 111 | TLSOpts: tlsOpts, 112 | }) 113 | 114 | config := ctrl.GetConfigOrDie() 115 | telemetry.WrapTransport(config) 116 | syncPeriod := time.Hour * 5 117 | mgr, err := ctrl.NewManager(config, ctrl.Options{ 118 | Scheme: scheme, 119 | Metrics: metricsserver.Options{ 120 | BindAddress: metricsAddr, 121 | SecureServing: secureMetrics, 122 | TLSOpts: tlsOpts, 123 | }, 124 | WebhookServer: webhookServer, 125 | HealthProbeBindAddress: probeAddr, 126 | LeaderElection: enableLeaderElection, 127 | LeaderElectionID: "ec156e4c.altinn.studio", 128 | // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily 129 | // when the Manager ends. This requires the binary to immediately end when the 130 | // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly 131 | // speeds up voluntary leader transitions as the new leader don't have to wait 132 | // LeaseDuration time first. 133 | // 134 | // In the default scaffold provided, the program ends immediately after 135 | // the manager stops, so would be fine to enable this option. However, 136 | // if you are doing or is intended to do any operation such as perform cleanups 137 | // after the manager stops then its usage might be unsafe. 138 | // LeaderElectionReleaseOnCancel: true, 139 | 140 | Cache: cache.Options{ 141 | // SyncPeriod will force additional reconciliations periodically 142 | SyncPeriod: &syncPeriod, 143 | }, 144 | }) 145 | if err != nil { 146 | setupLog.Error(err, "unable to start manager") 147 | span.End() 148 | os.Exit(1) 149 | } 150 | 151 | if err = (controller.NewMaskinportenClientReconciler( 152 | rt, 153 | mgr.GetClient(), 154 | mgr.GetScheme(), 155 | nil, 156 | )).SetupWithManager(mgr); err != nil { 157 | setupLog.Error(err, "unable to create controller", "controller", "MaskinportenClient") 158 | span.End() 159 | os.Exit(1) 160 | } 161 | // +kubebuilder:scaffold:builder 162 | 163 | if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { 164 | setupLog.Error(err, "unable to set up health check") 165 | span.End() 166 | os.Exit(1) 167 | } 168 | if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { 169 | setupLog.Error(err, "unable to set up ready check") 170 | span.End() 171 | os.Exit(1) 172 | } 173 | 174 | setupLog.Info("starting manager") 175 | span.End() 176 | 177 | if err := mgr.Start(ctx); err != nil { 178 | setupLog.Error(err, "problem running manager") 179 | os.Exit(1) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/altinn/altinn-k8s-operator 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.22.4 6 | 7 | require ( 8 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0 9 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 10 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.1.0 11 | github.com/cenkalti/backoff/v4 v4.3.0 12 | github.com/gkampitakis/go-snaps v0.5.7 13 | github.com/go-errors/errors v1.5.1 14 | github.com/go-jose/go-jose/v4 v4.0.2 15 | github.com/go-playground/validator/v10 v10.19.0 16 | github.com/google/uuid v1.6.0 17 | github.com/jonboulle/clockwork v0.4.0 18 | github.com/knadh/koanf/parsers/dotenv v1.0.0 19 | github.com/knadh/koanf/providers/file v0.1.0 20 | github.com/knadh/koanf/v2 v2.1.1 21 | github.com/onsi/ginkgo/v2 v2.17.1 22 | github.com/onsi/gomega v1.32.0 23 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0 24 | go.opentelemetry.io/otel v1.25.0 25 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.25.0 26 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.25.0 27 | go.opentelemetry.io/otel/metric v1.25.0 28 | go.opentelemetry.io/otel/sdk v1.25.0 29 | go.opentelemetry.io/otel/sdk/metric v1.25.0 30 | go.opentelemetry.io/otel/trace v1.25.0 31 | golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e 32 | k8s.io/api v0.30.0 33 | k8s.io/apimachinery v0.30.0 34 | k8s.io/client-go v0.30.0 35 | sigs.k8s.io/controller-runtime v0.18.2 36 | ) 37 | 38 | require ( 39 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.9.0 // indirect 40 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.1 // indirect 41 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect 42 | github.com/beorn7/perks v1.0.1 // indirect 43 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 44 | github.com/davecgh/go-spew v1.1.1 // indirect 45 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 46 | github.com/evanphx/json-patch/v5 v5.9.0 // indirect 47 | github.com/felixge/httpsnoop v1.0.4 // indirect 48 | github.com/fsnotify/fsnotify v1.7.0 // indirect 49 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 50 | github.com/gkampitakis/ciinfo v0.3.0 // indirect 51 | github.com/gkampitakis/go-diff v1.3.2 // indirect 52 | github.com/go-logr/logr v1.4.1 // indirect 53 | github.com/go-logr/stdr v1.2.2 // indirect 54 | github.com/go-logr/zapr v1.3.0 // indirect 55 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 56 | github.com/go-openapi/jsonreference v0.20.2 // indirect 57 | github.com/go-openapi/swag v0.22.3 // indirect 58 | github.com/go-playground/locales v0.14.1 // indirect 59 | github.com/go-playground/universal-translator v0.18.1 // indirect 60 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 61 | github.com/go-viper/mapstructure/v2 v2.0.0 // indirect 62 | github.com/gogo/protobuf v1.3.2 // indirect 63 | github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 64 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 65 | github.com/golang/protobuf v1.5.4 // indirect 66 | github.com/google/gnostic-models v0.6.8 // indirect 67 | github.com/google/go-cmp v0.6.0 // indirect 68 | github.com/google/gofuzz v1.2.0 // indirect 69 | github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect 70 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect 71 | github.com/imdario/mergo v0.3.6 // indirect 72 | github.com/joho/godotenv v1.5.1 // indirect 73 | github.com/josharian/intern v1.0.0 // indirect 74 | github.com/json-iterator/go v1.1.12 // indirect 75 | github.com/knadh/koanf/maps v0.1.1 // indirect 76 | github.com/kr/pretty v0.3.1 // indirect 77 | github.com/kr/text v0.2.0 // indirect 78 | github.com/kylelemons/godebug v1.1.0 // indirect 79 | github.com/leodido/go-urn v1.4.0 // indirect 80 | github.com/mailru/easyjson v0.7.7 // indirect 81 | github.com/maruel/natural v1.1.1 // indirect 82 | github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 83 | github.com/mitchellh/copystructure v1.2.0 // indirect 84 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 85 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 86 | github.com/modern-go/reflect2 v1.0.2 // indirect 87 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 88 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 89 | github.com/pkg/errors v0.9.1 // indirect 90 | github.com/prometheus/client_golang v1.18.0 // indirect 91 | github.com/prometheus/client_model v0.5.0 // indirect 92 | github.com/prometheus/common v0.45.0 // indirect 93 | github.com/prometheus/procfs v0.12.0 // indirect 94 | github.com/rogpeppe/go-internal v1.12.0 // indirect 95 | github.com/spf13/pflag v1.0.5 // indirect 96 | github.com/tidwall/gjson v1.17.0 // indirect 97 | github.com/tidwall/match v1.1.1 // indirect 98 | github.com/tidwall/pretty v1.2.1 // indirect 99 | github.com/tidwall/sjson v1.2.5 // indirect 100 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.25.0 // indirect 101 | go.opentelemetry.io/proto/otlp v1.1.0 // indirect 102 | go.uber.org/multierr v1.11.0 // indirect 103 | go.uber.org/zap v1.26.0 // indirect 104 | golang.org/x/crypto v0.24.0 // indirect 105 | golang.org/x/net v0.26.0 // indirect 106 | golang.org/x/oauth2 v0.17.0 // indirect 107 | golang.org/x/sys v0.21.0 // indirect 108 | golang.org/x/term v0.21.0 // indirect 109 | golang.org/x/text v0.16.0 // indirect 110 | golang.org/x/time v0.3.0 // indirect 111 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 112 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 113 | google.golang.org/appengine v1.6.8 // indirect 114 | google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect 115 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect 116 | google.golang.org/grpc v1.63.0 // indirect 117 | google.golang.org/protobuf v1.33.0 // indirect 118 | gopkg.in/inf.v0 v0.9.1 // indirect 119 | gopkg.in/yaml.v2 v2.4.0 // indirect 120 | gopkg.in/yaml.v3 v3.0.1 // indirect 121 | k8s.io/apiextensions-apiserver v0.30.0 // indirect 122 | k8s.io/klog/v2 v2.120.1 // indirect 123 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect 124 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect 125 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 126 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 127 | sigs.k8s.io/yaml v1.4.0 // indirect 128 | ) 129 | -------------------------------------------------------------------------------- /internal/crypto/crypto.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/rsa" 5 | "crypto/x509" 6 | "crypto/x509/pkix" 7 | "fmt" 8 | "io" 9 | "math/big" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/altinn/altinn-k8s-operator/internal/assert" 15 | "github.com/altinn/altinn-k8s-operator/internal/operatorcontext" 16 | "github.com/go-errors/errors" 17 | "github.com/go-jose/go-jose/v4" 18 | "github.com/google/uuid" 19 | "github.com/jonboulle/clockwork" 20 | ) 21 | 22 | const DefaultX509SignatureAlgo x509.SignatureAlgorithm = x509.SHA512WithRSA 23 | const DefaultKeySizeBits int = 4096 24 | 25 | type CryptoService struct { 26 | ctx *operatorcontext.Context 27 | clock clockwork.Clock 28 | random io.Reader 29 | signatureAlgo jose.SignatureAlgorithm 30 | x509SignatureAlgo x509.SignatureAlgorithm 31 | keySizeBits int 32 | } 33 | 34 | func NewService( 35 | ctx *operatorcontext.Context, 36 | clock clockwork.Clock, 37 | random io.Reader, 38 | x509SignatureAlgo x509.SignatureAlgorithm, 39 | keySizeBits int, 40 | ) *CryptoService { 41 | assert.AssertWith(x509SignatureAlgo != x509.UnknownSignatureAlgorithm, "x509 signature algorithm must be provided") 42 | assert.AssertWith(keySizeBits > 0, "key size in bits must be positive") 43 | 44 | signatureAlgo, ok := signatureAlgorithmFromX509(x509SignatureAlgo) 45 | assert.AssertWith(ok, "unsupported x509 signature algorithm: %v", x509SignatureAlgo) 46 | 47 | return &CryptoService{ 48 | ctx: ctx, 49 | clock: clock, 50 | random: random, 51 | signatureAlgo: signatureAlgo, 52 | x509SignatureAlgo: x509SignatureAlgo, 53 | keySizeBits: keySizeBits, 54 | } 55 | } 56 | 57 | func NewDefaultService( 58 | ctx *operatorcontext.Context, 59 | clock clockwork.Clock, 60 | random io.Reader, 61 | ) *CryptoService { 62 | return NewService(ctx, clock, random, DefaultX509SignatureAlgo, DefaultKeySizeBits) 63 | } 64 | 65 | // Creates a JWKS 66 | // Constructs the JWKS from the whole RSA private/public key pair 67 | // Uses SHA512 with RSA, 4096 bits for RSA 68 | func (s *CryptoService) CreateJwks(certCommonName string, notAfter time.Time) (*Jwks, error) { 69 | cert, rsaKey, err := s.createCert(certCommonName, notAfter) 70 | if err != nil { 71 | return nil, errors.WrapPrefix(err, "error creating JWKS cert", 0) 72 | } 73 | 74 | return s.createJWKS(cert, rsaKey, 0) 75 | } 76 | 77 | func (s *CryptoService) createJWKS( 78 | cert *x509.Certificate, 79 | rsaKey *rsa.PrivateKey, 80 | index int, 81 | ) (*Jwks, error) { 82 | id, err := uuid.NewRandomFromReader(s.random) 83 | if err != nil { 84 | return nil, err 85 | } 86 | keyId := fmt.Sprintf("%s.%d", id.String(), index) 87 | return NewJwks(NewJwk([]*x509.Certificate{cert}, rsaKey, keyId, "sig", string(s.signatureAlgo))), nil 88 | } 89 | 90 | func (s *CryptoService) generateCertSerialNumber() (*big.Int, error) { 91 | // x509 serial number is a 20 bytes unsigned integer 92 | // source: https://www.rfc-editor.org/rfc/rfc3280#section-4.1.2.2 93 | // 16 bytes (128 bits) should be enough to be unique - UUID v4 (random) uses 122 bits 94 | serial := new(big.Int) 95 | serialBytes := [16]byte{} 96 | n, err := io.ReadFull(s.random, serialBytes[:]) 97 | if err != nil { 98 | return nil, err 99 | } 100 | assert.AssertWith(n == len(serialBytes), "Read should always fill slice when err is nil") 101 | serial.SetBytes(serialBytes[:]) 102 | assert.AssertWith(serial.Sign() != -1, "SetBytes should treat bytes as an unsigned integer") 103 | return serial, nil 104 | } 105 | 106 | func (s *CryptoService) createCert( 107 | certCommonName string, 108 | notAfter time.Time, 109 | ) (*x509.Certificate, *rsa.PrivateKey, error) { 110 | rsaKey, err := rsa.GenerateKey(s.random, s.keySizeBits) 111 | if err != nil { 112 | return nil, nil, errors.WrapPrefix(err, "error generating RSA key for jwks", 0) 113 | } 114 | 115 | serial, err := s.generateCertSerialNumber() 116 | if err != nil { 117 | return nil, nil, errors.WrapPrefix(err, "error generating serial number for jwks", 0) 118 | } 119 | 120 | now := s.clock.Now().UTC() 121 | if now.Equal(notAfter) || now.After(notAfter) { 122 | return nil, nil, errors.Errorf("notAfter (%s) must be after current time (%s)", notAfter, now) 123 | } 124 | 125 | certTemplate := x509.Certificate{ 126 | SerialNumber: serial, 127 | Subject: pkix.Name{ 128 | Organization: []string{s.ctx.ServiceOwnerName}, 129 | CommonName: certCommonName, 130 | }, 131 | Issuer: s.getIssuer(), 132 | NotBefore: now, 133 | NotAfter: notAfter, 134 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 135 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 136 | BasicConstraintsValid: true, 137 | SignatureAlgorithm: s.x509SignatureAlgo, 138 | } 139 | 140 | derBytes, err := x509.CreateCertificate(s.random, &certTemplate, &certTemplate, &rsaKey.PublicKey, rsaKey) 141 | if err != nil { 142 | return nil, nil, errors.WrapPrefix(err, "error generating cert for jwks", 0) 143 | } 144 | cert, err := x509.ParseCertificate(derBytes) 145 | if err != nil { 146 | return nil, nil, errors.WrapPrefix(err, "error parsing generated cert for jwks", 0) 147 | } 148 | 149 | return cert, rsaKey, nil 150 | } 151 | 152 | func (s *CryptoService) RotateIfNeeded( 153 | certCommonName string, 154 | notAfter time.Time, 155 | currentJwks *Jwks, 156 | ) (*Jwks, error) { 157 | if currentJwks == nil { 158 | return nil, errors.New("cant rotate cert for JWKS, JWKS was null") 159 | } 160 | 161 | var activeKey *Jwk 162 | 163 | for i := 0; i < len(currentJwks.Keys); i++ { 164 | if activeKey == nil { 165 | activeKey = currentJwks.Keys[i] 166 | certificateCount := len(activeKey.Certificates()) 167 | if certificateCount != 1 { 168 | return nil, errors.Errorf( 169 | "unexpected number of certificates for key '%s': '%d'", 170 | activeKey.KeyID(), 171 | certificateCount, 172 | ) 173 | } 174 | 175 | } else { 176 | key := currentJwks.Keys[i] 177 | 178 | certificates := key.Certificates() 179 | certificateCount := len(certificates) 180 | if certificateCount != 1 { 181 | return nil, errors.Errorf("unexpected number of certificates for key '%s': '%d'", key.KeyID(), certificateCount) 182 | } 183 | 184 | cert := certificates[0] 185 | activeCert := activeKey.Certificates()[0] 186 | 187 | if cert.NotAfter.After(activeCert.NotAfter) { 188 | activeKey = key 189 | } 190 | } 191 | } 192 | 193 | rotationThreshold := s.clock.Now().UTC().Add(time.Hour * 24 * 7) 194 | if activeKey.Certificates()[0].NotAfter.After(rotationThreshold) { 195 | return nil, nil 196 | } else { 197 | keyParts := strings.Split(activeKey.KeyID(), ".") 198 | currentIndexStr := keyParts[len(keyParts)-1] 199 | currentIndex, err := strconv.Atoi(currentIndexStr) 200 | if err != nil { 201 | return nil, errors.Errorf("invalid key format: %s", activeKey.KeyID()) 202 | } 203 | cert, rsaKey, err := s.createCert(certCommonName, notAfter) 204 | if err != nil { 205 | return nil, errors.WrapPrefix(err, "error creating JWKS cert", 0) 206 | } 207 | 208 | newJwks, err := s.createJWKS(cert, rsaKey, currentIndex+1) 209 | if err != nil { 210 | return nil, err 211 | } 212 | newJwks.Keys = append(newJwks.Keys, activeKey) // TODO: verify that app-lib reads latest key 213 | return newJwks, nil 214 | } 215 | } 216 | 217 | func (s *CryptoService) getIssuer() pkix.Name { 218 | return pkix.Name{ 219 | Organization: []string{"Digdir"}, 220 | CommonName: "Altinn Operator", 221 | } 222 | } 223 | 224 | func SignatureAlgorithmNameFromX509(algo x509.SignatureAlgorithm) (string, bool) { 225 | name, ok := signatureAlgorithmFromX509(algo) 226 | if !ok { 227 | return "", false 228 | } 229 | return string(name), true 230 | } 231 | 232 | func DefaultSignatureAlgorithmName() string { 233 | name, ok := SignatureAlgorithmNameFromX509(DefaultX509SignatureAlgo) 234 | assert.AssertWith(ok, "default x509 signature algorithm must map to a JOSE signature algorithm") 235 | return name 236 | } 237 | 238 | func signatureAlgorithmFromX509(algo x509.SignatureAlgorithm) (jose.SignatureAlgorithm, bool) { 239 | switch algo { 240 | case x509.SHA256WithRSA: 241 | return jose.RS256, true 242 | case x509.SHA384WithRSA: 243 | return jose.RS384, true 244 | case x509.SHA512WithRSA: 245 | return jose.RS512, true 246 | case x509.SHA256WithRSAPSS: 247 | return jose.PS256, true 248 | case x509.SHA384WithRSAPSS: 249 | return jose.PS384, true 250 | case x509.SHA512WithRSAPSS: 251 | return jose.PS512, true 252 | default: 253 | return "", false 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /internal/maskinporten/client_request.go: -------------------------------------------------------------------------------- 1 | package maskinporten 2 | 3 | // AddClientRequest represents the request for creating a new client 4 | type AddClientRequest struct { 5 | // Id på klient, må være unik. Blir autogenerert ved opprettelse av klient om den ikke er spesifikt satt 6 | ClientId *string `json:"client_id,omitempty"` 7 | 8 | // Navn på klient, blir vist ved innlogging 9 | ClientName *string `json:"client_name,omitempty"` 10 | 11 | // Klienten sitt organisasjonsnummer. Må stemme med autentisert avsender, eller avsender må være leverandør med scopet idporten:dcr.supplier. 12 | ClientOrgno *string `json:"client_orgno,omitempty"` 13 | 14 | // Leverandøren sitt organisasjonsnummer. Skal kun settes hvis avsender har idporten:dcr.supplier og client_orgno er satt til noe annet enn avsenders orgno 15 | SupplierOrgno *string `json:"supplier_orgno,omitempty"` 16 | 17 | // Beskrivelse av klienten, ikke synlig for innbyggere, men blir lagret i Digdirs støttesystemer 18 | Description *string `json:"description,omitempty"` 19 | 20 | // Angir om klienten er aktiv eller ikke. Inaktive klienter vil ikke bli synkroniseret til ID-porten/maskinporten/ansattporten. Settes default til true 21 | Active *bool `json:"active,omitempty"` 22 | 23 | // Applikasjonstype 24 | ApplicationType *ApplicationType `json:"application_type,omitempty"` 25 | 26 | // Integrasjonstype 27 | IntegrationType *IntegrationType `json:"integration_type,omitempty"` 28 | 29 | // Liste over scopes som klienten kan forespørre. For OpenID Connect er aktuelle scopes openid og profile. For API-sikring, ta kontakt med oss 30 | Scopes []string `json:"scopes,omitempty"` 31 | 32 | // Tillatte Grant Types for klient. Implicit skal ikke tas i bruk av nye klienter(deprecated). 33 | GrantTypes []GrantType `json:"grant_types,omitempty"` 34 | 35 | // Autentiseringsmetode for klient. None anbefales for klienter som kjører i nettleser eller på mobil 36 | TokenEndpointAuthMethod *TokenEndpointAuthMethod `json:"token_endpoint_auth_method,omitempty"` 37 | 38 | // Levetid i sekunder for utstedt refresh_token. 39 | RefreshTokenLifetime *int64 `json:"refresh_token_lifetime,omitempty"` 40 | 41 | // Ved REUSE kan refresh_token benyttes flere ganger. Ved ONETIME kan refresh_token kun benyttes en gang. 42 | RefreshTokenUsage *RefreshTokenUsage `json:"refresh_token_usage,omitempty"` 43 | 44 | // Levetid i sekunder for utstedt access_token. 45 | AccessTokenLifetime *int64 `json:"access_token_lifetime,omitempty"` 46 | 47 | // Levetid for registrert autorisasjon i sekunder. I en OpenID Connect sammenheng vil dette være tilgangen til userinfo-endepunktet. 48 | AuthorizationLifetime *int64 `json:"authorization_lifetime,omitempty"` 49 | 50 | LogoUri *string `json:"logo_uri,omitempty"` 51 | 52 | // Liste over gyldige url'er som vi kan redirecte tilbake til etter vellykket autorisasjonsforespørsel 53 | RedirectUris []string `json:"redirect_uris,omitempty"` 54 | 55 | // Liste over url'er som vi redirecter til etter fullført utlogging 56 | PostLogoutRedirectUris []string `json:"post_logout_redirect_uris,omitempty"` 57 | 58 | // Flagg som bestemmer om parameterne for issuer og sesjons-id skal sendes med frontchannel_logout_uri 59 | FrontchannelLogoutSessionRequired *bool `json:"frontchannel_logout_session_required,omitempty"` 60 | 61 | // URL som vi sender request til ved utlogging trigget av annen klient i samme sesjon 62 | FrontchannelLogoutUri *string `json:"frontchannel_logout_uri,omitempty"` 63 | 64 | // Flagg for å disable sso. Dette vil gjøre at brukeren må logge inn på nytt for din klient. Dette er kun relevant for OpenID Connect. Om ikke satt, vil default være false for ID-porten og true for Ansattporten 65 | SsoDisabled *bool `json:"sso_disabled,omitempty"` 66 | 67 | // Code challenge method for PKCE. Gyldige verdier er none eller S256. Dette er kun relevant for OpenID Connect. Om ikke satt, vil default bli S256. 68 | CodeChallengeMethod *CodeChallengeMethod `json:"code_challenge_method,omitempty"` 69 | } 70 | 71 | // UpdateClientRequest represents the request for updating a client 72 | type UpdateClientRequest struct { 73 | // Id på klient som skal oppdateres 74 | ClientId *string `json:"client_id,omitempty"` 75 | 76 | // Navn på klient, blir vist ved innlogging 77 | ClientName *string `json:"client_name,omitempty"` 78 | 79 | // Klienten sitt organisasjonsnummer. Må stemme med autentisert avsender, eller avsender må være leverandør med scopet idporten:dcr.supplier. 80 | ClientOrgno *string `json:"client_orgno,omitempty"` 81 | 82 | // Leverandøren sitt organisasjonsnummer. Skal kun settes hvis avsender har idporten:dcr.supplier og client_orgno er satt til noe annet enn avsenders orgno 83 | SupplierOrgno *string `json:"supplier_orgno,omitempty"` 84 | 85 | // Beskrivelse av klienten, ikke synlig for innbyggere, men blir lagret i Digdirs støttesystemer 86 | Description *string `json:"description,omitempty"` 87 | 88 | // Angir om klienten er aktiv eller ikke. Inaktive klienter vil ikke bli synkroniseret til ID-porten/maskinporten/ansattporten. Settes default til true 89 | Active *bool `json:"active,omitempty"` 90 | 91 | // Applikasjonstype 92 | ApplicationType *ApplicationType `json:"application_type,omitempty"` 93 | 94 | // Integrasjonstype 95 | IntegrationType *IntegrationType `json:"integration_type,omitempty"` 96 | 97 | // Liste over scopes som klienten kan forespørre. For OpenID Connect er aktuelle scopes openid og profile. For API-sikring, ta kontakt med oss 98 | Scopes []string `json:"scopes,omitempty"` 99 | 100 | // Tillatte Grant Types for klient. Implicit skal ikke tas i bruk av nye klienter(deprecated). 101 | GrantTypes []GrantType `json:"grant_types,omitempty"` 102 | 103 | // Autentiseringsmetode for klient. None anbefales for klienter som kjører i nettleser eller på mobil 104 | TokenEndpointAuthMethod *TokenEndpointAuthMethod `json:"token_endpoint_auth_method,omitempty"` 105 | 106 | // Levetid i sekunder for utstedt refresh_token. 107 | RefreshTokenLifetime *int64 `json:"refresh_token_lifetime,omitempty"` 108 | 109 | // Ved REUSE kan refresh_token benyttes flere ganger. Ved ONETIME kan refresh_token kun benyttes en gang. 110 | RefreshTokenUsage *RefreshTokenUsage `json:"refresh_token_usage,omitempty"` 111 | 112 | // Levetid i sekunder for utstedt access_token. 113 | AccessTokenLifetime *int64 `json:"access_token_lifetime,omitempty"` 114 | 115 | // Levetid for registrert autorisasjon i sekunder. I en OpenID Connect sammenheng vil dette være tilgangen til userinfo-endepunktet. 116 | AuthorizationLifetime *int64 `json:"authorization_lifetime,omitempty"` 117 | 118 | LogoUri *string `json:"logo_uri,omitempty"` 119 | 120 | // Liste over gyldige url'er som vi kan redirecte tilbake til etter vellykket autorisasjonsforespørsel 121 | RedirectUris []string `json:"redirect_uris,omitempty"` 122 | 123 | // Liste over url'er som vi redirecter til etter fullført utlogging 124 | PostLogoutRedirectUris []string `json:"post_logout_redirect_uris,omitempty"` 125 | 126 | // Flagg som bestemmer om parameterne for issuer og sesjons-id skal sendes med frontchannel_logout_uri 127 | FrontchannelLogoutSessionRequired *bool `json:"frontchannel_logout_session_required,omitempty"` 128 | 129 | // URL som vi sender request til ved utlogging trigget av annen klient i samme sesjon 130 | FrontchannelLogoutUri *string `json:"frontchannel_logout_uri,omitempty"` 131 | 132 | // Flagg for å disable sso. Dette vil gjøre at brukeren må logge inn på nytt for din klient. Dette er kun relevant for OpenID Connect. Om ikke satt, vil default være false for ID-porten og true for Ansattporten 133 | SsoDisabled *bool `json:"sso_disabled,omitempty"` 134 | 135 | // Code challenge method for PKCE. Gyldige verdier er none eller S256. Dette er kun relevant for OpenID Connect. Om ikke satt, vil default bli S256. 136 | CodeChallengeMethod *CodeChallengeMethod `json:"code_challenge_method,omitempty"` 137 | } 138 | 139 | type ApplicationType string 140 | 141 | const ( 142 | ApplicationTypeWeb ApplicationType = "web" 143 | ApplicationTypeBrowser ApplicationType = "browser" 144 | ApplicationTypeNative ApplicationType = "native" 145 | ) 146 | 147 | type IntegrationType string 148 | 149 | const ( 150 | IntegrationTypeAnsattporten IntegrationType = "ansattporten" 151 | IntegrationTypeApiKlient IntegrationType = "api_klient" 152 | IntegrationTypeEformidling IntegrationType = "eformidling" 153 | IntegrationTypeIdporten IntegrationType = "idporten" 154 | IntegrationTypeIdportenSaml2 IntegrationType = "idporten_saml2" 155 | IntegrationTypeKrr IntegrationType = "krr" 156 | IntegrationTypeMaskinporten IntegrationType = "maskinporten" 157 | ) 158 | 159 | type GrantType string 160 | 161 | const ( 162 | GrantTypeAuthorizationCode GrantType = "authorization_code" 163 | GrantTypeImplicit GrantType = "implicit" 164 | GrantTypeRefreshToken GrantType = "refresh_token" 165 | GrantTypeJwtBearer GrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" 166 | ) 167 | 168 | type TokenEndpointAuthMethod string 169 | 170 | const ( 171 | TokenEndpointAuthMethodClientSecretPost TokenEndpointAuthMethod = "client_secret_post" 172 | TokenEndpointAuthMethodClientSecretBasic TokenEndpointAuthMethod = "client_secret_basic" 173 | TokenEndpointAuthMethodPrivateKeyJwt TokenEndpointAuthMethod = "private_key_jwt" 174 | TokenEndpointAuthMethodNone TokenEndpointAuthMethod = "none" 175 | ) 176 | 177 | type RefreshTokenUsage string 178 | 179 | const ( 180 | RefreshTokenUsageReuse RefreshTokenUsage = "REUSE" 181 | RefreshTokenUsageOnetime RefreshTokenUsage = "ONETIME" 182 | ) 183 | 184 | type CodeChallengeMethod string 185 | 186 | const ( 187 | CodeChallengeMethodNone CodeChallengeMethod = "none" 188 | CodeChallengeMethodS256 CodeChallengeMethod = "S256" 189 | ) 190 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Image URL to use all building/pushing image targets 2 | IMG ?= controller:latest 3 | # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. 4 | ENVTEST_K8S_VERSION = 1.30.0 5 | 6 | # Go build cache is written inside the repo to work in sandboxed environments. 7 | export GOCACHE ?= $(shell pwd)/.gocache 8 | benchtime ?= 1s 9 | 10 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 11 | ifeq (,$(shell go env GOBIN)) 12 | GOBIN=$(shell go env GOPATH)/bin 13 | else 14 | GOBIN=$(shell go env GOBIN) 15 | endif 16 | 17 | # CONTAINER_TOOL defines the container tool to be used for building images. 18 | # Be aware that the target commands are only tested with Docker which is 19 | # scaffolded by default. However, you might want to replace it to use other 20 | # tools. (i.e. podman) 21 | CONTAINER_TOOL ?= docker 22 | 23 | # Setting SHELL to bash allows bash commands to be executed by recipes. 24 | # Options are set to exit when a recipe line exits non-zero or a piped command fails. 25 | SHELL = /usr/bin/env bash -o pipefail 26 | .SHELLFLAGS = -ec 27 | 28 | .PHONY: all 29 | all: build 30 | 31 | ##@ General 32 | 33 | # The help target prints out all targets with their descriptions organized 34 | # beneath their categories. The categories are represented by '##@' and the 35 | # target descriptions by '##'. The awk command is responsible for reading the 36 | # entire set of makefiles included in this invocation, looking for lines of the 37 | # file as xyz: ## something, and then pretty-format the target and help. Then, 38 | # if there's a line with ##@ something, that gets pretty-printed as a category. 39 | # More info on the usage of ANSI control characters for terminal formatting: 40 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 41 | # More info on the awk command: 42 | # http://linuxcommand.org/lc3_adv_awk.php 43 | 44 | .PHONY: help 45 | help: ## Display this help. 46 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 47 | 48 | ##@ Development 49 | 50 | .PHONY: manifests 51 | manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. 52 | $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases 53 | 54 | .PHONY: generate 55 | generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. 56 | $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." 57 | 58 | .PHONY: fmt 59 | fmt: ## Run go fmt against code. 60 | go fmt ./... 61 | 62 | .PHONY: vet 63 | vet: ## Run go vet against code. 64 | go vet ./... 65 | 66 | .PHONY: dockercompose 67 | dockercompose: ## Start local fake APIs for testing 68 | @if [ "$(SKIP_DOCKER_COMPOSE)" = "1" ]; then \ 69 | echo "Skipping docker compose"; \ 70 | else \ 71 | docker compose up -d --build; \ 72 | fi 73 | 74 | .PHONY: test 75 | test: dockercompose manifests generate fmt vet envtest ## Run tests. 76 | KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out 77 | 78 | # Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. 79 | .PHONY: test-e2e # Run the e2e tests against a Kind k8s instance that is spun up. 80 | test-e2e: 81 | go test ./test/e2e/ -v -ginkgo.v 82 | 83 | 84 | 85 | .PHONY: bench 86 | bench: ## Run benchmarks. Usage: make bench filter= [packages=./internal/crypto] [benchtime=1s] 87 | @if [ -z "$(filter)" ]; then \ 88 | echo "filter argument required, e.g. make bench filter=BenchmarkCreateJwks" >&2; \ 89 | exit 1; \ 90 | fi 91 | packages="$(if $(packages),$(packages),./...)"; \ 92 | go test -run=^$$ -bench=$(filter) -benchmem -benchtime=$(benchtime) $$packages 93 | 94 | .PHONY: lint 95 | lint: golangci-lint ## Run golangci-lint linter 96 | $(GOLANGCI_LINT) run 97 | 98 | .PHONY: lint-fix 99 | lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes 100 | $(GOLANGCI_LINT) run --fix 101 | 102 | ##@ Build 103 | 104 | .PHONY: build 105 | build: manifests generate fmt vet ## Build manager binary. 106 | go build -o bin/manager cmd/main.go 107 | 108 | .PHONY: otel 109 | otel: ## Build and start the telemetry services using docker-compose 110 | @$(CONTAINER_TOOL) compose up --build 111 | 112 | .PHONY: run 113 | run: manifests generate fmt vet ## Run a controller from your host. 114 | go run ./cmd/main.go 115 | 116 | # If you wish to build the manager image targeting other platforms you can use the --platform flag. 117 | # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. 118 | # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ 119 | .PHONY: docker-build 120 | docker-build: ## Build docker image with the manager. 121 | $(CONTAINER_TOOL) build -t ${IMG} . 122 | 123 | .PHONY: docker-push 124 | docker-push: ## Push docker image with the manager. 125 | $(CONTAINER_TOOL) push ${IMG} 126 | 127 | # PLATFORMS defines the target platforms for the manager image be built to provide support to multiple 128 | # architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to: 129 | # - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/ 130 | # - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ 131 | # - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) 132 | # To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. 133 | PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le 134 | .PHONY: docker-buildx 135 | docker-buildx: ## Build and push docker image for the manager for cross-platform support 136 | # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile 137 | sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross 138 | - $(CONTAINER_TOOL) buildx create --name altinn-k8s-operator-builder 139 | $(CONTAINER_TOOL) buildx use altinn-k8s-operator-builder 140 | - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . 141 | - $(CONTAINER_TOOL) buildx rm altinn-k8s-operator-builder 142 | rm Dockerfile.cross 143 | 144 | .PHONY: build-installer 145 | build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. 146 | mkdir -p dist 147 | cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} 148 | $(KUSTOMIZE) build config/default > dist/install.yaml 149 | 150 | ##@ Deployment 151 | 152 | ifndef ignore-not-found 153 | ignore-not-found = false 154 | endif 155 | 156 | .PHONY: install 157 | install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. 158 | $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f - 159 | 160 | .PHONY: uninstall 161 | uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 162 | $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - 163 | 164 | .PHONY: deploy 165 | deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. 166 | cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} 167 | $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f - 168 | 169 | .PHONY: undeploy 170 | undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 171 | $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - 172 | 173 | ##@ Dependencies 174 | 175 | ## Location to install dependencies to 176 | LOCALBIN ?= $(shell pwd)/bin 177 | $(LOCALBIN): 178 | mkdir -p $(LOCALBIN) 179 | 180 | ## Tool Binaries 181 | KUBECTL ?= kubectl 182 | KUSTOMIZE ?= $(LOCALBIN)/kustomize-$(KUSTOMIZE_VERSION) 183 | CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen-$(CONTROLLER_TOOLS_VERSION) 184 | ENVTEST ?= $(LOCALBIN)/setup-envtest-$(ENVTEST_VERSION) 185 | GOLANGCI_LINT = $(LOCALBIN)/golangci-lint-$(GOLANGCI_LINT_VERSION) 186 | 187 | ## Tool Versions 188 | KUSTOMIZE_VERSION ?= v5.4.1 189 | CONTROLLER_TOOLS_VERSION ?= v0.15.0 190 | ENVTEST_VERSION ?= release-0.18 191 | GOLANGCI_LINT_VERSION ?= v1.57.2 192 | 193 | .PHONY: kustomize 194 | kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. 195 | $(KUSTOMIZE): $(LOCALBIN) 196 | $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION)) 197 | 198 | .PHONY: controller-gen 199 | controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. 200 | $(CONTROLLER_GEN): $(LOCALBIN) 201 | $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION)) 202 | 203 | .PHONY: envtest 204 | envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. 205 | $(ENVTEST): $(LOCALBIN) 206 | $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) 207 | 208 | .PHONY: golangci-lint 209 | golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. 210 | $(GOLANGCI_LINT): $(LOCALBIN) 211 | $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,${GOLANGCI_LINT_VERSION}) 212 | 213 | # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist 214 | # $1 - target path with name of binary (ideally with version) 215 | # $2 - package url which can be installed 216 | # $3 - specific version of package 217 | define go-install-tool 218 | @[ -f $(1) ] || { \ 219 | set -e; \ 220 | package=$(2)@$(3) ;\ 221 | echo "Downloading $${package}" ;\ 222 | GOBIN=$(LOCALBIN) go install $${package} ;\ 223 | mv "$$(echo "$(1)" | sed "s/-$(3)$$//")" $(1) ;\ 224 | } 225 | endef 226 | -------------------------------------------------------------------------------- /cmd/utils/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/json" 7 | "flag" 8 | "fmt" 9 | "os" 10 | "time" 11 | 12 | "github.com/altinn/altinn-k8s-operator/internal/config" 13 | "github.com/altinn/altinn-k8s-operator/internal/crypto" 14 | "github.com/altinn/altinn-k8s-operator/internal/maskinporten" 15 | "github.com/altinn/altinn-k8s-operator/internal/operatorcontext" 16 | "github.com/jonboulle/clockwork" 17 | ) 18 | 19 | func main() { 20 | if len(os.Args) < 2 { 21 | fmt.Fprintf(os.Stderr, "Usage: %s [options]\n", os.Args[0]) 22 | fmt.Fprintf(os.Stderr, "Commands:\n") 23 | fmt.Fprintf(os.Stderr, " get Get commands\n") 24 | fmt.Fprintf(os.Stderr, " create Create commands\n") 25 | os.Exit(1) 26 | } 27 | 28 | command := os.Args[1] 29 | 30 | switch command { 31 | case "get": 32 | if len(os.Args) < 3 { 33 | fmt.Fprintf(os.Stderr, "Usage: %s get [options]\n", os.Args[0]) 34 | fmt.Fprintf(os.Stderr, "Subcommands:\n") 35 | fmt.Fprintf(os.Stderr, " token Get a Maskinporten access token\n") 36 | fmt.Fprintf(os.Stderr, " clients List Maskinporten clients\n") 37 | os.Exit(1) 38 | } 39 | 40 | subcommand := os.Args[2] 41 | switch subcommand { 42 | case "token": 43 | getToken() 44 | case "clients": 45 | getClients() 46 | default: 47 | fmt.Fprintf(os.Stderr, "Unknown subcommand: %s\n", subcommand) 48 | os.Exit(1) 49 | } 50 | case "create": 51 | if len(os.Args) < 3 { 52 | fmt.Fprintf(os.Stderr, "Usage: %s create [options]\n", os.Args[0]) 53 | fmt.Fprintf(os.Stderr, "Subcommands:\n") 54 | fmt.Fprintf(os.Stderr, " jwk Create a JSON Web Key Set\n") 55 | os.Exit(1) 56 | } 57 | 58 | subcommand := os.Args[2] 59 | switch subcommand { 60 | case "jwk": 61 | createJwk() 62 | default: 63 | fmt.Fprintf(os.Stderr, "Unknown subcommand: %s\n", subcommand) 64 | os.Exit(1) 65 | } 66 | case "delete": 67 | if len(os.Args) < 3 { 68 | fmt.Fprintf(os.Stderr, "Usage: %s delete [options]\n", os.Args[0]) 69 | fmt.Fprintf(os.Stderr, "Subcommands:\n") 70 | fmt.Fprintf(os.Stderr, " client Delete a Maskinporten client\n") 71 | os.Exit(1) 72 | } 73 | 74 | subcommand := os.Args[2] 75 | switch subcommand { 76 | case "client": 77 | deleteClient() 78 | default: 79 | fmt.Fprintf(os.Stderr, "Unknown subcommand: %s\n", subcommand) 80 | os.Exit(1) 81 | } 82 | default: 83 | fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) 84 | os.Exit(1) 85 | } 86 | } 87 | 88 | func getToken() { 89 | // Create a new flag set for the get token subcommand 90 | fs := flag.NewFlagSet("get token", flag.ExitOnError) 91 | var envFile string 92 | var verbose bool 93 | fs.StringVar(&envFile, "env", "dev.env", "Environment file to load configuration from") 94 | fs.BoolVar(&verbose, "verbose", false, "Print configuration information to stderr") 95 | 96 | // Parse remaining args (skip program name, "get", "token") 97 | err := fs.Parse(os.Args[3:]) 98 | if err != nil { 99 | fmt.Fprintf(os.Stderr, "Failed to parse flags: %v\n", err) 100 | os.Exit(1) 101 | } 102 | 103 | ctx := context.Background() 104 | 105 | // Create operator context 106 | operatorCtx, err := operatorcontext.Discover(ctx) 107 | if err != nil { 108 | fmt.Fprintf(os.Stderr, "Failed to discover operator context: %v\n", err) 109 | os.Exit(1) 110 | } 111 | 112 | // Load configuration from env file 113 | cfg, err := config.GetConfig(operatorCtx, config.ConfigSourceKoanf, envFile) 114 | if err != nil { 115 | fmt.Fprintf(os.Stderr, "Failed to load config from %s: %v\n", envFile, err) 116 | os.Exit(1) 117 | } 118 | 119 | // Print configuration information if verbose 120 | if verbose { 121 | fmt.Fprintf(os.Stderr, "Configuration loaded from: %s\n", envFile) 122 | fmt.Fprintf(os.Stderr, "Authority URL: %s\n", cfg.MaskinportenApi.AuthorityUrl) 123 | fmt.Fprintf(os.Stderr, "Self Service URL: %s\n", cfg.MaskinportenApi.SelfServiceUrl) 124 | fmt.Fprintf(os.Stderr, "Client ID: %s\n", cfg.MaskinportenApi.ClientId) 125 | fmt.Fprintf(os.Stderr, "Scope: %s\n", cfg.MaskinportenApi.Scope) 126 | fmt.Fprintf(os.Stderr, "---\n") 127 | } 128 | 129 | // Create HTTP API client 130 | clock := clockwork.NewRealClock() 131 | client, err := maskinporten.NewHttpApiClient(&cfg.MaskinportenApi, operatorCtx, clock) 132 | if err != nil { 133 | fmt.Fprintf(os.Stderr, "Failed to create Maskinporten client: %v\n", err) 134 | os.Exit(1) 135 | } 136 | 137 | // Get the access token 138 | tokenResponse, err := client.GetAccessToken(ctx) 139 | if err != nil { 140 | fmt.Fprintf(os.Stderr, "Failed to get access token: %v\n", err) 141 | os.Exit(1) 142 | } 143 | 144 | // Output just the token to stdout 145 | fmt.Println(tokenResponse.AccessToken) 146 | } 147 | 148 | func getClients() { 149 | fs := flag.NewFlagSet("get clients", flag.ExitOnError) 150 | var envFile string 151 | var verbose bool 152 | var pretty bool 153 | fs.StringVar(&envFile, "env", "dev.env", "Environment file to load configuration from") 154 | fs.BoolVar(&verbose, "verbose", false, "Print configuration information to stderr") 155 | fs.BoolVar(&pretty, "pretty", false, "Format JSON output with indentation") 156 | 157 | err := fs.Parse(os.Args[3:]) 158 | if err != nil { 159 | fmt.Fprintf(os.Stderr, "Failed to parse flags: %v\n", err) 160 | os.Exit(1) 161 | } 162 | 163 | ctx, cfg, client, err := setupMaskinportenClient(envFile) 164 | if err != nil { 165 | fmt.Fprintf(os.Stderr, "%v\n", err) 166 | os.Exit(1) 167 | } 168 | 169 | if verbose { 170 | fmt.Fprintf(os.Stderr, "Configuration loaded from: %s\n", envFile) 171 | fmt.Fprintf(os.Stderr, "Self Service URL: %s\n", cfg.MaskinportenApi.SelfServiceUrl) 172 | fmt.Fprintf(os.Stderr, "---\n") 173 | } 174 | 175 | clients, err := client.GetAllClients(ctx) 176 | if err != nil { 177 | fmt.Fprintf(os.Stderr, "Failed to get clients: %v\n", err) 178 | os.Exit(1) 179 | } 180 | 181 | if verbose { 182 | fmt.Fprintf(os.Stderr, "Clients fetched: %d\n", len(clients)) 183 | fmt.Fprintf(os.Stderr, "---\n") 184 | } 185 | 186 | var output []byte 187 | if pretty { 188 | output, err = json.MarshalIndent(clients, "", " ") 189 | } else { 190 | output, err = json.Marshal(clients) 191 | } 192 | if err != nil { 193 | fmt.Fprintf(os.Stderr, "Failed to marshal clients to JSON: %v\n", err) 194 | os.Exit(1) 195 | } 196 | 197 | fmt.Println(string(output)) 198 | } 199 | 200 | func deleteClient() { 201 | fs := flag.NewFlagSet("delete client", flag.ExitOnError) 202 | var envFile string 203 | var clientID string 204 | var verbose bool 205 | fs.StringVar(&envFile, "env", "dev.env", "Environment file to load configuration from") 206 | fs.StringVar(&clientID, "client-id", "", "Maskinporten client ID to delete") 207 | fs.BoolVar(&verbose, "verbose", false, "Print configuration information to stderr") 208 | 209 | err := fs.Parse(os.Args[3:]) 210 | if err != nil { 211 | fmt.Fprintf(os.Stderr, "Failed to parse flags: %v\n", err) 212 | os.Exit(1) 213 | } 214 | 215 | if clientID == "" { 216 | fmt.Fprintf(os.Stderr, "--client-id flag is required\n") 217 | fs.Usage() 218 | os.Exit(1) 219 | } 220 | 221 | ctx, cfg, client, err := setupMaskinportenClient(envFile) 222 | if err != nil { 223 | fmt.Fprintf(os.Stderr, "%v\n", err) 224 | os.Exit(1) 225 | } 226 | 227 | if verbose { 228 | fmt.Fprintf(os.Stderr, "Configuration loaded from: %s\n", envFile) 229 | fmt.Fprintf(os.Stderr, "Self Service URL: %s\n", cfg.MaskinportenApi.SelfServiceUrl) 230 | fmt.Fprintf(os.Stderr, "Deleting client ID: %s\n", clientID) 231 | fmt.Fprintf(os.Stderr, "---\n") 232 | } 233 | 234 | if err := client.DeleteClient(ctx, clientID); err != nil { 235 | fmt.Fprintf(os.Stderr, "Failed to delete client: %v\n", err) 236 | os.Exit(1) 237 | } 238 | 239 | if verbose { 240 | fmt.Fprintf(os.Stderr, "Client deleted: %s\n", clientID) 241 | } 242 | 243 | fmt.Println(clientID) 244 | } 245 | 246 | func setupMaskinportenClient(envFile string) (context.Context, *config.Config, *maskinporten.HttpApiClient, error) { 247 | ctx := context.Background() 248 | 249 | operatorCtx, err := operatorcontext.Discover(ctx) 250 | if err != nil { 251 | return nil, nil, nil, fmt.Errorf("failed to discover operator context: %w", err) 252 | } 253 | 254 | cfg, err := config.GetConfig(operatorCtx, config.ConfigSourceKoanf, envFile) 255 | if err != nil { 256 | return nil, nil, nil, fmt.Errorf("failed to load config from %s: %w", envFile, err) 257 | } 258 | 259 | clock := clockwork.NewRealClock() 260 | client, err := maskinporten.NewHttpApiClient(&cfg.MaskinportenApi, operatorCtx, clock) 261 | if err != nil { 262 | return nil, nil, nil, fmt.Errorf("failed to create Maskinporten client: %w", err) 263 | } 264 | 265 | return ctx, cfg, client, nil 266 | } 267 | 268 | func createJwk() { 269 | // Create a new flag set for the create jwk subcommand 270 | fs := flag.NewFlagSet("create jwk", flag.ExitOnError) 271 | var certCommonName string 272 | var notAfterStr string 273 | var verbose bool 274 | var pretty bool 275 | fs.StringVar(&certCommonName, "cert-common-name", "default-cert", "Common name for the certificate") 276 | fs.StringVar( 277 | ¬AfterStr, 278 | "not-after", 279 | "", 280 | "Certificate expiration time (RFC3339 format, e.g., 2024-12-31T23:59:59Z)", 281 | ) 282 | fs.BoolVar(&verbose, "verbose", false, "Print crypto configuration constants to stderr") 283 | fs.BoolVar(&pretty, "pretty", false, "Format JSON output with indentation") 284 | 285 | // Parse remaining args (skip program name, "create", "jwk") 286 | err := fs.Parse(os.Args[3:]) 287 | if err != nil { 288 | fmt.Fprintf(os.Stderr, "Failed to parse flags: %v\n", err) 289 | os.Exit(1) 290 | } 291 | 292 | // Parse the notAfter time 293 | var notAfter time.Time 294 | if notAfterStr == "" { 295 | // Default to 1 year from now 296 | notAfter = time.Now().Add(time.Hour * 24 * 365) 297 | } else { 298 | notAfter, err = time.Parse(time.RFC3339, notAfterStr) 299 | if err != nil { 300 | fmt.Fprintf(os.Stderr, "Failed to parse not-after time: %v\n", err) 301 | fmt.Fprintf(os.Stderr, "Expected RFC3339 format, e.g., 2024-12-31T23:59:59Z\n") 302 | os.Exit(1) 303 | } 304 | } 305 | 306 | ctx := context.Background() 307 | 308 | // Create operator context 309 | operatorCtx, err := operatorcontext.Discover(ctx) 310 | if err != nil { 311 | fmt.Fprintf(os.Stderr, "Failed to discover operator context: %v\n", err) 312 | os.Exit(1) 313 | } 314 | 315 | // Print crypto constants if verbose 316 | if verbose { 317 | fmt.Fprintf(os.Stderr, "Crypto configuration:\n") 318 | fmt.Fprintf(os.Stderr, "Signature Algorithm: %s\n", crypto.DefaultSignatureAlgorithmName()) 319 | fmt.Fprintf(os.Stderr, "X.509 Signature Algorithm: %v\n", crypto.DefaultX509SignatureAlgo) 320 | fmt.Fprintf(os.Stderr, "Key Size (bits): %d\n", crypto.DefaultKeySizeBits) 321 | fmt.Fprintf(os.Stderr, "Certificate Common Name: %s\n", certCommonName) 322 | fmt.Fprintf(os.Stderr, "Certificate Not After: %s\n", notAfter.Format(time.RFC3339)) 323 | fmt.Fprintf(os.Stderr, "---\n") 324 | } 325 | 326 | // Create crypto service 327 | clock := clockwork.NewRealClock() 328 | cryptoService := crypto.NewDefaultService(operatorCtx, clock, rand.Reader) 329 | 330 | // Create JWKS 331 | jwks, err := cryptoService.CreateJwks(certCommonName, notAfter) 332 | if err != nil { 333 | fmt.Fprintf(os.Stderr, "Failed to create JWKS: %v\n", err) 334 | os.Exit(1) 335 | } 336 | 337 | // Output the JWKS as JSON 338 | var jwkJson []byte 339 | var publicJwkJson []byte 340 | jwk := jwks.Keys[0] 341 | publicJwk := jwk.Public() 342 | if pretty { 343 | jwkJson, err = json.MarshalIndent(jwk, "", " ") 344 | } else { 345 | jwkJson, err = json.Marshal(jwk) 346 | } 347 | if err != nil { 348 | fmt.Fprintf(os.Stderr, "Failed to marshal JWK to JSON: %v\n", err) 349 | os.Exit(1) 350 | } 351 | if pretty { 352 | publicJwkJson, err = json.MarshalIndent(publicJwk, "", " ") 353 | } else { 354 | publicJwkJson, err = json.Marshal(publicJwk) 355 | } 356 | if err != nil { 357 | fmt.Fprintf(os.Stderr, "Failed to marshal public JWK to JSON: %v\n", err) 358 | os.Exit(1) 359 | } 360 | 361 | fmt.Println(string(jwkJson)) 362 | fmt.Println("---") 363 | fmt.Println(string(publicJwkJson)) 364 | } 365 | -------------------------------------------------------------------------------- /internal/maskinporten/http_api_client_test.go: -------------------------------------------------------------------------------- 1 | package maskinporten 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | "time" 11 | 12 | "github.com/altinn/altinn-k8s-operator/internal/caching" 13 | "github.com/altinn/altinn-k8s-operator/internal/config" 14 | "github.com/altinn/altinn-k8s-operator/internal/operatorcontext" 15 | "github.com/google/uuid" 16 | "github.com/jonboulle/clockwork" 17 | "github.com/onsi/gomega" 18 | . "github.com/onsi/gomega" 19 | ) 20 | 21 | type testApi struct { 22 | path string 23 | statusCode int 24 | responseBody string 25 | } 26 | 27 | func getMaskinportenApiFixture( 28 | g *gomega.WithT, 29 | generateApis func(cfg *config.Config) (apis []testApi), 30 | ) (*httptest.Server, *config.Config, *operatorcontext.Context) { 31 | operatorContext := operatorcontext.DiscoverOrDie(context.Background()) 32 | cfg := config.GetConfigOrDie(operatorContext, config.ConfigSourceDefault, "") 33 | 34 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 35 | apis := generateApis(cfg) 36 | for _, api := range apis { 37 | if api.path != r.URL.Path { 38 | continue 39 | } 40 | 41 | w.WriteHeader(api.statusCode) 42 | if api.responseBody != "" { 43 | w.Header().Add("Content-Type", "application/json") 44 | _, err := w.Write([]byte(api.responseBody)) 45 | g.Expect(err).NotTo(HaveOccurred()) 46 | } 47 | return 48 | } 49 | 50 | w.WriteHeader(http.StatusNotFound) 51 | })) 52 | 53 | cfg.MaskinportenApi.AuthorityUrl = server.URL 54 | return server, cfg, operatorContext 55 | } 56 | 57 | func okWellKnownHandler(g *gomega.WithT, cfg *config.Config) testApi { 58 | tokenEndpoint, err := url.JoinPath(cfg.MaskinportenApi.AuthorityUrl, "/token") 59 | g.Expect(err).NotTo(HaveOccurred()) 60 | jwksEndpoint, err := url.JoinPath(cfg.MaskinportenApi.AuthorityUrl, "/jwk") 61 | g.Expect(err).NotTo(HaveOccurred()) 62 | body := fmt.Sprintf( 63 | `{"issuer":"%s","token_endpoint":"%s","jwks_uri":"%s","token_endpoint_auth_methods_supported":["private_key_jwt"],"grant_types_supported":["urn:ietf:params:oauth:grant-type:jwt-bearer"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512"],"authorization_details_types_supported":["urn:altinn:systemuser"]}`, 64 | cfg.MaskinportenApi.AuthorityUrl, 65 | tokenEndpoint, 66 | jwksEndpoint, 67 | ) 68 | 69 | return testApi{"/.well-known/oauth-authorization-server", http.StatusOK, body} 70 | } 71 | 72 | func getMaskinportenApiWellKnownFixture( 73 | g *gomega.WithT, 74 | statusCode int, 75 | ) (*httptest.Server, *config.Config, *operatorcontext.Context) { 76 | return getMaskinportenApiFixture( 77 | g, 78 | func(cfg *config.Config) (apis []testApi) { 79 | if statusCode == http.StatusOK { 80 | return []testApi{okWellKnownHandler(g, cfg)} 81 | } else { 82 | return []testApi{{"/.well-known/oauth-authorization-server", statusCode, ""}} 83 | } 84 | }, 85 | ) 86 | } 87 | 88 | func TestFixtureIsNotRemote(t *testing.T) { 89 | g := NewWithT(t) 90 | 91 | operatorContext := operatorcontext.DiscoverOrDie(context.Background()) 92 | configBefore := config.GetConfigOrDie(operatorContext, config.ConfigSourceDefault, "") 93 | 94 | server, configAfter, _ := getMaskinportenApiWellKnownFixture(g, http.StatusOK) 95 | defer server.Close() 96 | 97 | g.Expect(configAfter.MaskinportenApi.AuthorityUrl).NotTo(Equal(configBefore.MaskinportenApi.AuthorityUrl)) 98 | g.Expect(configAfter.MaskinportenApi.AuthorityUrl).To(ContainSubstring("http://127.0.0.1")) 99 | } 100 | 101 | func TestWellKnownConfigOk(t *testing.T) { 102 | g := NewWithT(t) 103 | ctx := context.Background() 104 | clock := clockwork.NewFakeClock() 105 | 106 | server, cfg, opCtx := getMaskinportenApiWellKnownFixture(g, http.StatusOK) 107 | defer server.Close() 108 | 109 | apiClient, err := NewHttpApiClient(&cfg.MaskinportenApi, opCtx, clock) 110 | g.Expect(err).NotTo(HaveOccurred()) 111 | 112 | config, err := apiClient.GetWellKnownConfiguration(ctx) 113 | g.Expect(err).NotTo(HaveOccurred()) 114 | g.Expect(config).NotTo(BeNil()) 115 | tokenEndpoint, err := url.JoinPath(cfg.MaskinportenApi.AuthorityUrl, "/token") 116 | g.Expect(err).NotTo(HaveOccurred()) 117 | g.Expect(config.TokenEndpoint).To(Equal(tokenEndpoint)) 118 | } 119 | 120 | func TestWellKnownConfigNotFound(t *testing.T) { 121 | g := NewWithT(t) 122 | ctx := context.Background() 123 | clock := clockwork.NewFakeClock() 124 | 125 | server, cfg, opCtx := getMaskinportenApiWellKnownFixture(g, http.StatusNotFound) 126 | defer server.Close() 127 | 128 | apiClient, err := NewHttpApiClient(&cfg.MaskinportenApi, opCtx, clock) 129 | g.Expect(err).NotTo(HaveOccurred()) 130 | 131 | config, err := apiClient.GetWellKnownConfiguration(ctx) 132 | g.Expect(err).To(HaveOccurred()) 133 | g.Expect(config).To(BeNil()) 134 | } 135 | 136 | func TestWellKnownConfigCaches(t *testing.T) { 137 | g := NewWithT(t) 138 | ctx := context.Background() 139 | clock := clockwork.NewFakeClock() 140 | 141 | server, cfg, opCtx := getMaskinportenApiWellKnownFixture(g, http.StatusOK) 142 | defer server.Close() 143 | 144 | apiClient, err := NewHttpApiClient(&cfg.MaskinportenApi, opCtx, clock) 145 | g.Expect(err).NotTo(HaveOccurred()) 146 | 147 | config1, err := apiClient.GetWellKnownConfiguration(ctx) 148 | g.Expect(err).NotTo(HaveOccurred()) 149 | g.Expect(config1).NotTo(BeNil()) 150 | 151 | config2, err := apiClient.GetWellKnownConfiguration(ctx) 152 | g.Expect(err).NotTo(HaveOccurred()) 153 | g.Expect(config2).NotTo(BeNil()) 154 | config3 := *config1 155 | g.Expect(config1).To(BeIdenticalTo(config2)) // Due to cache 156 | g.Expect(config1).ToNot(BeIdenticalTo(&config3)) // Copied above 157 | 158 | clock.Advance((5 + 1) * time.Minute) // Advance the clock past cache expiration 159 | 160 | config4, err := apiClient.GetWellKnownConfiguration(ctx) 161 | g.Expect(err).NotTo(HaveOccurred()) 162 | g.Expect(config4).NotTo(BeNil()) 163 | g.Expect(config1).ToNot(BeIdenticalTo(config4)) // Due to cache expiration 164 | } 165 | 166 | func TestCreateGrant(t *testing.T) { 167 | g := NewWithT(t) 168 | ctx := context.Background() 169 | clock := clockwork.NewFakeClock() 170 | 171 | server, cfg, opCtx := getMaskinportenApiWellKnownFixture(g, http.StatusOK) 172 | defer server.Close() 173 | 174 | client, err := NewHttpApiClient(&cfg.MaskinportenApi, opCtx, clock) 175 | g.Expect(err).NotTo(HaveOccurred()) 176 | 177 | grant, err := client.createGrant(ctx) 178 | g.Expect(err).NotTo(HaveOccurred()) 179 | g.Expect(grant).NotTo(BeNil()) 180 | } 181 | 182 | func getMaskinportenApiAccessTokenFixture( 183 | g *gomega.WithT, 184 | statusCode int, 185 | ) (*httptest.Server, *config.Config, *operatorcontext.Context, string) { 186 | accessToken := uuid.NewString() 187 | 188 | server, cfg, opCtx := getMaskinportenApiFixture( 189 | g, 190 | func(cfg *config.Config) (apis []testApi) { 191 | var body string 192 | if statusCode == http.StatusOK { 193 | body = fmt.Sprintf( 194 | `{"access_token":"%s","token_type":"Bearer","expires_in":3600}`, 195 | accessToken, 196 | ) 197 | } 198 | return []testApi{ 199 | okWellKnownHandler(g, cfg), 200 | {"/token", statusCode, body}, 201 | } 202 | }, 203 | ) 204 | 205 | return server, cfg, opCtx, accessToken 206 | } 207 | 208 | func TestFetchAccessToken(t *testing.T) { 209 | g := NewWithT(t) 210 | ctx := context.Background() 211 | clock := clockwork.NewFakeClock() 212 | 213 | server, cfg, opCtx, accessToken := getMaskinportenApiAccessTokenFixture(g, http.StatusOK) 214 | defer server.Close() 215 | 216 | client, err := NewHttpApiClient(&cfg.MaskinportenApi, opCtx, clock) 217 | g.Expect(err).NotTo(HaveOccurred()) 218 | 219 | token, err := client.accessTokenFetcher(ctx) 220 | g.Expect(err).NotTo(HaveOccurred()) 221 | g.Expect(token.AccessToken).To(Equal(accessToken)) 222 | } 223 | 224 | func TestFetchAccessTokenReal(t *testing.T) { 225 | t.Skip("Only used for adhoc integration testing") 226 | 227 | g := NewWithT(t) 228 | ctx := context.Background() 229 | clock := clockwork.NewFakeClock() 230 | 231 | operatorContext := operatorcontext.DiscoverOrDie(ctx) 232 | operatorContext.OverrideEnvironment(operatorcontext.EnvironmentDev) 233 | cfg := config.GetConfigOrDie( 234 | operatorContext, 235 | config.ConfigSourceDefault, 236 | "", 237 | ) 238 | client, err := NewHttpApiClient(&cfg.MaskinportenApi, operatorContext, clock) 239 | g.Expect(err).NotTo(HaveOccurred()) 240 | 241 | tokenResponse, err := client.accessTokenFetcher(ctx) 242 | g.Expect(err).NotTo(HaveOccurred()) 243 | g.Expect(tokenResponse).NotTo(BeNil()) 244 | } 245 | 246 | // func TestFetchClientsReal(t *testing.T) { 247 | // t.Skip("Only used for adhoc integration testing") 248 | 249 | // g := NewWithT(t) 250 | // ctx := context.Background() 251 | // clock := clockwork.NewFakeClock() 252 | 253 | // operatorContext := operatorcontext.DiscoverOrDie(ctx) 254 | // operatorContext.OverrideEnvironment(operatorcontext.EnvironmentDev) 255 | // cfg := config.GetConfigOrDie( 256 | // operatorContext, 257 | // config.ConfigSourceDefault, 258 | // "", 259 | // ) 260 | // client, err := newApiClient(&cfg.MaskinportenApi, operatorContext, clock) 261 | // g.Expect(err).NotTo(HaveOccurred()) 262 | 263 | // clients, err := client.getAllClients(ctx) 264 | // g.Expect(err).NotTo(HaveOccurred()) 265 | // g.Expect(clients).NotTo(BeNil()) 266 | // } 267 | 268 | // func TestCreateClientReal(t *testing.T) { 269 | // t.Skip("Only used for adhoc integration testing") 270 | 271 | // g := NewWithT(t) 272 | // ctx := context.Background() 273 | // clock := clockwork.NewFakeClock() 274 | 275 | // operatorContext := operatorcontext.DiscoverOrDie(ctx) 276 | // operatorContext.OverrideEnvironment(operatorcontext.EnvironmentDev) 277 | // cfg := config.GetConfigOrDie( 278 | // operatorContext, 279 | // config.ConfigSourceDefault, 280 | // "", 281 | // ) 282 | // client, err := newApiClient(&cfg.MaskinportenApi, operatorContext, clock) 283 | // g.Expect(err).NotTo(HaveOccurred()) 284 | 285 | // mpClient := &ClientInfo{ 286 | // Id: "", 287 | // AppId: "app1", 288 | // Scopes: []string{"altinn:serviceowner/instances.read"}, 289 | // } 290 | // err = client.createClient(ctx, mpClient) 291 | // g.Expect(err).NotTo(HaveOccurred()) 292 | // g.Expect(mpClient.Id).NotTo(BeEmpty()) 293 | // } 294 | 295 | // func TestDeleteClientReal(t *testing.T) { 296 | // t.Skip("Only used for adhoc integration testing") 297 | 298 | // g := NewWithT(t) 299 | // ctx := context.Background() 300 | // clock := clockwork.NewFakeClock() 301 | 302 | // operatorContext := operatorcontext.DiscoverOrDie(ctx) 303 | // operatorContext.OverrideEnvironment(operatorcontext.EnvironmentDev) 304 | // cfg := config.GetConfigOrDie( 305 | // operatorContext, 306 | // config.ConfigSourceDefault, 307 | // "", 308 | // ) 309 | // client, err := NewHttpApiClient(&cfg.MaskinportenApi, operatorContext, clock) 310 | // g.Expect(err).NotTo(HaveOccurred()) 311 | 312 | // clients, err := client.GetAllClients(ctx) 313 | // g.Expect(err).NotTo(HaveOccurred()) 314 | // g.Expect(clients).NotTo(BeNil()) 315 | 316 | // for i := range clients { 317 | // err = client.DeleteClient(ctx, clients[i].ClientId) 318 | // g.Expect(err).NotTo(HaveOccurred()) 319 | // } 320 | // } 321 | 322 | func TestCreateReq(t *testing.T) { 323 | g := NewWithT(t) 324 | ctx := context.Background() 325 | clock := clockwork.NewFakeClock() 326 | 327 | accessToken := uuid.NewString() 328 | client := &HttpApiClient{ 329 | // Setup mock for accessToken with a custom retriever function. 330 | // This Cached[tokenResponse] instance will return the mock token when Get is called. 331 | accessToken: caching.NewCachedAtom(time.Minute*5, clock, func(ctx context.Context) (*TokenResponse, error) { 332 | // Return a mock tokenResponse 333 | return &TokenResponse{AccessToken: accessToken}, nil 334 | }), 335 | } 336 | 337 | var url = "http://example.com/api/endpoint" 338 | 339 | req, err := client.createReq(ctx, url, "POST", nil) 340 | g.Expect(err).NotTo(HaveOccurred()) 341 | g.Expect(req).NotTo(BeNil()) 342 | g.Expect(req.Method).To(Equal("POST")) 343 | g.Expect(req.URL.String()).To(Equal(url)) 344 | expectedHeader := fmt.Sprintf("Bearer %s", accessToken) 345 | g.Expect(req.Header.Get("Authorization")).To(Equal(expectedHeader)) 346 | } 347 | -------------------------------------------------------------------------------- /internal/controller/maskinportenclient_controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand/v2" 7 | "reflect" 8 | "time" 9 | 10 | "go.opentelemetry.io/otel/attribute" 11 | "go.opentelemetry.io/otel/codes" 12 | "go.opentelemetry.io/otel/trace" 13 | corev1 "k8s.io/api/core/v1" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/apimachinery/pkg/runtime" 16 | ctrl "sigs.k8s.io/controller-runtime" 17 | "sigs.k8s.io/controller-runtime/pkg/client" 18 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 19 | "sigs.k8s.io/controller-runtime/pkg/log" 20 | "sigs.k8s.io/controller-runtime/pkg/predicate" 21 | 22 | resourcesv1alpha1 "github.com/altinn/altinn-k8s-operator/api/v1alpha1" 23 | "github.com/altinn/altinn-k8s-operator/internal/assert" 24 | "github.com/altinn/altinn-k8s-operator/internal/crypto" 25 | "github.com/altinn/altinn-k8s-operator/internal/maskinporten" 26 | rt "github.com/altinn/altinn-k8s-operator/internal/runtime" 27 | ) 28 | 29 | const JsonFileName = "maskinporten-settings.json" 30 | const FinalizerName = "client.altinn.operator/finalizer" 31 | 32 | // MaskinportenClientReconciler reconciles a MaskinportenClient object 33 | type MaskinportenClientReconciler struct { 34 | client.Client 35 | Scheme *runtime.Scheme 36 | runtime rt.Runtime 37 | random *rand.Rand 38 | } 39 | 40 | func NewMaskinportenClientReconciler( 41 | rt rt.Runtime, 42 | client client.Client, 43 | scheme *runtime.Scheme, 44 | random *rand.Rand, 45 | ) *MaskinportenClientReconciler { 46 | if random == nil { 47 | random = rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) 48 | } 49 | return &MaskinportenClientReconciler{ 50 | Client: client, 51 | Scheme: scheme, 52 | runtime: rt, 53 | random: random, 54 | } 55 | } 56 | 57 | // +kubebuilder:rbac:groups=resources.altinn.studio,resources=maskinportenclients,verbs=get;list;watch;create;update;patch;delete 58 | // +kubebuilder:rbac:groups=resources.altinn.studio,resources=maskinportenclients/status,verbs=get;update;patch 59 | // +kubebuilder:rbac:groups=resources.altinn.studio,resources=maskinportenclients/finalizers,verbs=update 60 | 61 | // Reconcile is part of the main kubernetes reconciliation loop which aims to 62 | // move the current state of the cluster closer to the desired state. 63 | // 64 | // For more details, check Reconcile and its Result here: 65 | // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.17.0/pkg/reconcile 66 | func (r *MaskinportenClientReconciler) Reconcile(ctx context.Context, kreq ctrl.Request) (ctrl.Result, error) { 67 | ctx, span := r.runtime.Tracer().Start( 68 | ctx, 69 | "Reconcile", 70 | trace.WithAttributes(attribute.String("namespace", kreq.Namespace), attribute.String("name", kreq.Name)), 71 | ) 72 | defer span.End() 73 | 74 | log := log.FromContext(ctx) 75 | 76 | log.Info("Reconciling MaskinportenClient") 77 | 78 | // Mechanics of `Reconcile`: 79 | // * Returning errors requeues the request 80 | // * Returning TerminalError makes the request not retried (still logged as error) 81 | // * Returning empty result means no requeue 82 | // * Returning result with RequeueAfter set will requeue after the specified duration 83 | // * Returning result with Requeue set will requeue immediately 84 | 85 | req, err := r.mapRequest(ctx, kreq) 86 | if err != nil { 87 | span.SetStatus(codes.Error, "mapRequest failed") 88 | span.RecordError(err) 89 | return ctrl.Result{}, err 90 | } 91 | 92 | span.SetAttributes(attribute.String("app_id", req.AppId)) 93 | 94 | err = r.loadInstance(ctx, req) 95 | if err != nil { 96 | notFoundIgnored := client.IgnoreNotFound(err) 97 | if notFoundIgnored != nil { 98 | span.SetStatus(codes.Error, "getInstance failed") 99 | span.RecordError(err) 100 | log.Error(err, "Reconciling MaskinportenClient errored") 101 | } else { 102 | log.Info("Reconciling MaskinportenClient skipped, was deleted (so we have removed finalizer)..") 103 | // TODO: we end up here with NotFound after having cleaned up and removed finalizer.. why? 104 | } 105 | return ctrl.Result{}, notFoundIgnored 106 | } 107 | instance := req.Instance 108 | 109 | span.SetAttributes( 110 | attribute.String("request_kind", req.Kind.String()), 111 | attribute.Int64("generation", instance.GetGeneration()), 112 | ) 113 | 114 | currentState, err := r.fetchCurrentState(ctx, req) 115 | if err != nil { 116 | r.updateStatusWithError(ctx, err, "fetchCurrentState failed", instance, nil) 117 | return ctrl.Result{}, err 118 | } 119 | 120 | executedCommands, err := r.reconcile(ctx, currentState) 121 | if err != nil { 122 | r.updateStatusWithError(ctx, err, "reconcile failed", instance, executedCommands) 123 | return ctrl.Result{}, err 124 | } 125 | 126 | if len(executedCommands) == 0 { 127 | log.Info("No actions taken") 128 | span.SetStatus(codes.Ok, "reconciled successfully") 129 | return ctrl.Result{}, nil 130 | } 131 | 132 | reason := fmt.Sprintf("Reconciled %d resources", len(executedCommands)) 133 | err = r.updateStatus(ctx, req, instance, "reconciled", reason, executedCommands) 134 | if err != nil { 135 | span.SetStatus(codes.Error, "updateStatus failed") 136 | span.RecordError(err) 137 | log.Error(err, "Failed to update MaskinportenClient status") 138 | return ctrl.Result{}, err 139 | } 140 | 141 | log.Info("Reconciled MaskinportenClient") 142 | 143 | span.SetStatus(codes.Ok, "reconciled successfully") 144 | return ctrl.Result{RequeueAfter: r.getRequeueAfter()}, nil 145 | } 146 | 147 | func (r *MaskinportenClientReconciler) getRequeueAfter() time.Duration { 148 | return r.randomizeDuration(r.runtime.GetConfig().Controller.RequeueAfter, 10.0) 149 | } 150 | 151 | func (r *MaskinportenClientReconciler) randomizeDuration(d time.Duration, perc float64) time.Duration { 152 | max := int64(float64(d) * (perc / 100.0)) 153 | min := -max 154 | return d + time.Duration(r.random.Int64N(max-min)+min) 155 | } 156 | 157 | func (r *MaskinportenClientReconciler) updateStatus( 158 | ctx context.Context, 159 | req *maskinportenClientRequest, 160 | instance *resourcesv1alpha1.MaskinportenClient, 161 | state string, 162 | reason string, 163 | commands maskinporten.CommandList, 164 | ) error { 165 | ctx, span := r.runtime.Tracer().Start(ctx, "Reconcile.updateStatus") 166 | defer span.End() 167 | 168 | log := log.FromContext(ctx) 169 | 170 | instance.Status.State = state 171 | timestamp := metav1.Now() 172 | instance.Status.LastSynced = ×tamp 173 | instance.Status.Reason = reason 174 | if commands != nil { 175 | instance.Status.LastActions = commands.Strings() 176 | } else { 177 | instance.Status.LastActions = nil 178 | } 179 | instance.Status.ObservedGeneration = instance.GetGeneration() 180 | 181 | for _, cmd := range commands { 182 | // log.Info("Executed command", "command", cmd.String()) 183 | switch data := cmd.Data.(type) { 184 | case *maskinporten.CreateClientInApiCommand: 185 | instance.Status.ClientId = data.Api.ClientId 186 | case *maskinporten.UpdateClientInApiCommand: 187 | instance.Status.ClientId = data.Api.ClientId 188 | case *maskinporten.DeleteClientInApiCommand: 189 | instance.Status.ClientId = "" 190 | case *maskinporten.UpdateSecretContentCommand: 191 | instance.Status.Authority = data.SecretContent.Authority 192 | instance.Status.KeyIds = make([]string, len(data.SecretContent.Jwks.Keys)) 193 | for i, key := range data.SecretContent.Jwks.Keys { 194 | instance.Status.KeyIds[i] = key.KeyID() 195 | } 196 | case *maskinporten.DeleteSecretContentCommand: 197 | instance.Status.Authority = "" 198 | instance.Status.KeyIds = nil 199 | } 200 | } 201 | 202 | updatedFinalizers := false 203 | if req != nil { 204 | if req.Kind == RequestCreateKind { 205 | updatedFinalizers = controllerutil.AddFinalizer(instance, FinalizerName) 206 | } else if req.Kind == RequestDeleteKind { 207 | updatedFinalizers = controllerutil.RemoveFinalizer(instance, FinalizerName) 208 | } 209 | } 210 | 211 | var err error 212 | if updatedFinalizers { 213 | err = r.Update(ctx, instance) 214 | } else { 215 | err = r.Status().Update(ctx, instance) 216 | } 217 | 218 | if err != nil { 219 | span.SetStatus(codes.Error, "failed to update status") 220 | span.RecordError(err) 221 | log.Error(err, "Failed to update MaskinportenClient status") 222 | } 223 | 224 | return err 225 | } 226 | 227 | func (r *MaskinportenClientReconciler) updateStatusWithError( 228 | ctx context.Context, 229 | origError error, 230 | msg string, 231 | instance *resourcesv1alpha1.MaskinportenClient, 232 | commands maskinporten.CommandList, 233 | ) { 234 | origSpan := trace.SpanFromContext(ctx) 235 | log := log.FromContext(ctx) 236 | log.Error(origError, "Reconciliation of MaskinportenClient failed", "failure", msg) 237 | 238 | origSpan.SetStatus(codes.Error, msg) 239 | origSpan.RecordError(origError) 240 | 241 | _ = r.updateStatus(ctx, nil, instance, "error", msg, commands) 242 | } 243 | 244 | func (r *MaskinportenClientReconciler) loadInstance( 245 | ctx context.Context, 246 | req *maskinportenClientRequest, 247 | ) error { 248 | ctx, span := r.runtime.Tracer().Start(ctx, "Reconcile.getInstance") 249 | defer span.End() 250 | 251 | instance := &resourcesv1alpha1.MaskinportenClient{} 252 | if err := r.Get(ctx, req.NamespacedName, instance); err != nil { 253 | return fmt.Errorf("failed to get MaskinportenClient: %w", err) 254 | } 255 | 256 | req.Instance = instance 257 | 258 | if instance.ObjectMeta.DeletionTimestamp.IsZero() { 259 | if !controllerutil.ContainsFinalizer(instance, FinalizerName) { 260 | req.Kind = RequestCreateKind 261 | if err := r.updateStatus(ctx, req, instance, "recorded", "", nil); err != nil { 262 | return err 263 | } 264 | } else { 265 | req.Kind = RequestUpdateKind 266 | } 267 | } else { 268 | req.Kind = RequestDeleteKind 269 | } 270 | 271 | return nil 272 | } 273 | 274 | func (r *MaskinportenClientReconciler) fetchCurrentState( 275 | ctx context.Context, 276 | req *maskinportenClientRequest, 277 | ) (*maskinporten.ClientState, error) { 278 | ctx, span := r.runtime.Tracer().Start(ctx, "Reconcile.fetchCurrentState") 279 | defer span.End() 280 | 281 | apiClient := r.runtime.GetMaskinportenApiClient() 282 | 283 | var secrets corev1.SecretList 284 | err := r.List(ctx, &secrets, client.InNamespace(req.Namespace), client.MatchingLabels{"app": req.AppLabel}) 285 | if err != nil { 286 | return nil, err 287 | } 288 | if len(secrets.Items) > 1 { 289 | return nil, fmt.Errorf("unexpected number of secrets found: %d", len(secrets.Items)) 290 | } 291 | 292 | var secret *corev1.Secret 293 | if len(secrets.Items) == 1 { 294 | secret = &secrets.Items[0] 295 | if secret.Type != corev1.SecretTypeOpaque { 296 | return nil, fmt.Errorf("unexpected secret type: %s (expected Opaque)", secret.Type) 297 | } 298 | } 299 | 300 | var client *maskinporten.ClientResponse 301 | var jwks *crypto.Jwks 302 | var secretStateContent *maskinporten.SecretStateContent 303 | 304 | if secret != nil { 305 | secretStateContent, err = maskinporten.DeserializeSecretStateContent(secret) 306 | if err != nil { 307 | return nil, err 308 | } 309 | } 310 | 311 | if secretStateContent != nil { 312 | if secretStateContent.ClientId != "" { 313 | client, jwks, err = apiClient.GetClient(ctx, secretStateContent.ClientId) 314 | if err != nil { 315 | return nil, err 316 | } 317 | } 318 | } else { 319 | // If the secret state isn't updated, we still try to find a matching client in the API 320 | // In a previous iteration, we may have succeeded in creating the client in the API, 321 | // but failed to update the secret state content. 322 | 323 | allClients, err := apiClient.GetAllClients(ctx) 324 | if err != nil { 325 | return nil, err 326 | } 327 | 328 | clientName := maskinporten.GetClientName(r.runtime.GetOperatorContext(), req.AppId) 329 | for _, c := range allClients { 330 | if c.ClientName != nil && *c.ClientName == clientName { 331 | client, jwks, err = apiClient.GetClient(ctx, c.ClientId) 332 | if err != nil { 333 | return nil, err 334 | } 335 | break 336 | } 337 | } 338 | } 339 | 340 | clientState, err := maskinporten.NewClientState(req.Instance, client, jwks, secret, secretStateContent) 341 | if err != nil { 342 | return nil, err 343 | } 344 | 345 | return clientState, nil 346 | } 347 | 348 | func (r *MaskinportenClientReconciler) reconcile( 349 | ctx context.Context, 350 | currentState *maskinporten.ClientState, 351 | ) (maskinporten.CommandList, error) { 352 | ctx, span := r.runtime.Tracer().Start(ctx, "Reconcile.reconcile") 353 | defer span.End() 354 | 355 | context := r.runtime.GetOperatorContext() 356 | config := r.runtime.GetConfig() 357 | crypto := r.runtime.GetCrypto() 358 | clock := r.runtime.GetClock() 359 | commands, err := currentState.Reconcile(context, config, crypto, clock) 360 | if err != nil { 361 | return nil, err 362 | } 363 | 364 | executedCommands := make(maskinporten.CommandList, 0, len(commands)) 365 | 366 | apiClient := r.runtime.GetMaskinportenApiClient() 367 | 368 | for i := 0; i < len(commands); i++ { 369 | cmd := &commands[i] 370 | 371 | switch data := cmd.Data.(type) { 372 | case *maskinporten.CreateClientInApiCommand: 373 | resp, err := apiClient.CreateClient(ctx, data.Api.Req, data.Api.Jwks) 374 | if err != nil { 375 | return executedCommands, err 376 | } 377 | err = cmd.Callback(&maskinporten.CreateClientInApiCommandResponse{Resp: resp}) 378 | if err != nil { 379 | return executedCommands, err 380 | } 381 | case *maskinporten.UpdateClientInApiCommand: 382 | if data.Api.Req != nil { 383 | updateReq := maskinporten.ConvertAddRequestToUpdateRequest(data.Api.Req) 384 | _, err := apiClient.UpdateClient(ctx, data.Api.ClientId, updateReq) 385 | if err != nil { 386 | return executedCommands, err 387 | } 388 | } 389 | if data.Api.Jwks != nil { 390 | // TODO: verify assumed behavior of JWKS endpoints 391 | err := apiClient.CreateClientJwks(ctx, data.Api.ClientId, data.Api.Jwks) 392 | if err != nil { 393 | return executedCommands, err 394 | } 395 | } 396 | case *maskinporten.UpdateSecretContentCommand: 397 | assert.AssertWith( 398 | data.SecretContent.ClientId != "", 399 | "UpdateSecretContentCommand should always have client ID", 400 | ) 401 | updatedSecret := currentState.Secret.Manifest.DeepCopy() 402 | err := data.SecretContent.SerializeTo(updatedSecret) 403 | if err != nil { 404 | return executedCommands, err 405 | } 406 | 407 | if err := r.Update(ctx, updatedSecret); err != nil { 408 | return executedCommands, err 409 | } 410 | case *maskinporten.DeleteClientInApiCommand: 411 | err := apiClient.DeleteClient(ctx, data.ClientId) 412 | if err != nil { 413 | return executedCommands, err 414 | } 415 | case *maskinporten.DeleteSecretContentCommand: 416 | updatedSecret := currentState.Secret.Manifest.DeepCopy() 417 | maskinporten.DeleteSecretStateContent(updatedSecret) 418 | 419 | // TODO: ownerreference? 420 | if err := r.Update(ctx, updatedSecret); err != nil { 421 | return executedCommands, err 422 | } 423 | default: 424 | assert.AssertWith(false, "unhandled command: %s", reflect.TypeOf(cmd.Data).Name()) 425 | } 426 | 427 | executedCommands = append(executedCommands, *cmd) 428 | } 429 | 430 | return executedCommands, nil 431 | } 432 | 433 | // SetupWithManager sets up the controller with the Manager. 434 | func (r *MaskinportenClientReconciler) SetupWithManager(mgr ctrl.Manager) error { 435 | return ctrl.NewControllerManagedBy(mgr). 436 | For(&resourcesv1alpha1.MaskinportenClient{}). 437 | // Only reconcile on generation change (which does not change when status or metadata change) 438 | WithEventFilter(predicate.GenerationChangedPredicate{}). 439 | Complete(r) 440 | } 441 | --------------------------------------------------------------------------------