├── CODEOWNERS ├── docs ├── sequence.png └── sequence.puml ├── pkg ├── azure │ ├── consts.go │ ├── result │ │ ├── operation.go │ │ ├── preauthorizedapp.go │ │ └── application.go │ ├── client │ │ ├── preauthorizedapp │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── directoryobject │ │ │ └── owner.go │ │ ├── application │ │ │ ├── requiredresourceaccess │ │ │ │ ├── requiredresourceaccess_test.go │ │ │ │ └── requiredresourceaccess.go │ │ │ ├── approle │ │ │ │ ├── result.go │ │ │ │ ├── approles.go │ │ │ │ ├── approle.go │ │ │ │ ├── approlemap.go │ │ │ │ ├── approle_test.go │ │ │ │ └── approlemap_test.go │ │ │ ├── permissionscope │ │ │ │ ├── result.go │ │ │ │ ├── oauth2permissionscope.go │ │ │ │ ├── permissionscope.go │ │ │ │ ├── permissionscopemap.go │ │ │ │ └── permissionscope_test.go │ │ │ ├── owners │ │ │ │ └── owners.go │ │ │ ├── groupmembershipclaim │ │ │ │ └── groupmembershipclaim.go │ │ │ ├── optionalclaims │ │ │ │ ├── optionalclaims.go │ │ │ │ └── optionalclaims_test.go │ │ │ ├── identifieruri │ │ │ │ ├── identifieruri.go │ │ │ │ └── identifieruri_test.go │ │ │ └── redirecturi │ │ │ │ ├── redirecturi.go │ │ │ │ └── redirecturi_test.go │ │ ├── approleassignment │ │ │ ├── approleassignment.go │ │ │ ├── list.go │ │ │ └── approleassignment_test.go │ │ ├── serviceprincipal │ │ │ ├── owners.go │ │ │ └── policies.go │ │ ├── oauth2permissiongrant │ │ │ └── oauth2permissiongrant.go │ │ ├── auth.go │ │ └── credentials.go │ ├── runtimeclient.go │ ├── fake │ │ ├── azureopenidconfig.go │ │ ├── msgraph │ │ │ ├── serviceprincipal.go │ │ │ └── application.go │ │ ├── client │ │ │ └── client.go │ │ └── application.go │ ├── typealiases.go │ ├── util │ │ ├── strings.go │ │ ├── strings_test.go │ │ └── application_builder.go │ ├── credentials │ │ └── credentials.go │ ├── client.go │ ├── permissions │ │ └── const.go │ └── resource │ │ └── resource.go ├── util │ ├── strings │ │ ├── strings.go │ │ └── strings_test.go │ ├── crypto │ │ ├── keypair.go │ │ ├── jwk.go │ │ └── certificate.go │ └── test │ │ └── helpers.go ├── customresources │ ├── accesspolicyrule.go │ ├── azureadapplication.go │ └── azureadapplication_test.go ├── labels │ └── labels.go ├── transaction │ ├── secrets │ │ └── secrets.go │ ├── options │ │ ├── tenant.go │ │ ├── options.go │ │ └── process.go │ └── transaction.go ├── logger │ └── logger.go ├── retry │ └── retry.go ├── fixtures │ └── azureadapplication.go ├── event │ └── event.go ├── kafka │ ├── producer.go │ └── consumer.go ├── reconciler │ ├── interfaces.go │ └── finalizer │ │ └── finalizer.go ├── config │ └── azureopenidconfig.go ├── annotations │ ├── annotations.go │ └── annotations_test.go ├── synchronizer │ ├── synchronizer_test.go │ └── synchronizer.go ├── metrics │ └── metrics.go └── secrets │ └── secrets.go ├── PROJECT ├── hack └── resources │ ├── 00-namespace.yaml │ ├── 01-serviceaccount.yaml │ ├── 03-deployment.yaml │ └── 02-rbac.yaml ├── version.sh ├── charts ├── Chart.yaml ├── .helmignore ├── templates │ ├── serviceaccount.yaml │ ├── topic.yaml │ ├── netpol.yaml │ ├── application.yaml │ ├── alert.yaml │ ├── _helpers.tpl │ ├── rbac.yaml │ └── secret.yaml ├── Feature.yaml └── values.yaml ├── mise.toml ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── dependabot-auto-merge.yaml │ └── main.yaml ├── Dockerfile ├── config └── samples │ └── azureadapplication.yaml ├── LICENSE ├── Makefile └── cmd └── azurerator └── main.go /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @nais/pig-sikkerhet 2 | -------------------------------------------------------------------------------- /docs/sequence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nais/azurerator/HEAD/docs/sequence.png -------------------------------------------------------------------------------- /pkg/azure/consts.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | const ( 4 | AzureratorPrefix = "azurerator" 5 | ) 6 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | domain: nais.io 2 | repo: github.com/nais/azureator 3 | resources: 4 | - group: nais.io 5 | kind: AzureAdApplication 6 | version: v1 7 | version: "2" 8 | -------------------------------------------------------------------------------- /hack/resources/00-namespace.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: Namespace 3 | apiVersion: v1 4 | metadata: 5 | name: azurerator-system 6 | labels: 7 | name: azurerator-system 8 | -------------------------------------------------------------------------------- /hack/resources/01-serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | labels: 6 | app: azurerator 7 | name: azurerator 8 | namespace: azurerator-system -------------------------------------------------------------------------------- /pkg/azure/result/operation.go: -------------------------------------------------------------------------------- 1 | package result 2 | 3 | type Operation int 4 | 5 | const ( 6 | OperationCreated Operation = iota 7 | OperationUpdated 8 | OperationNotModified 9 | ) 10 | -------------------------------------------------------------------------------- /version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | dirty="" 3 | test -z "$(git ls-files --exclude-standard --others)" 4 | if [ $? -ne 0 ]; then 5 | dirty="-dirty" 6 | fi 7 | echo "$(date "+%Y-%m-%d")-$(git --no-pager log -1 --pretty=%h)${dirty}" 8 | -------------------------------------------------------------------------------- /charts/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: azurerator 3 | description: Operator that reconciles Azure AD applications. 4 | type: application 5 | version: 0.8.0 6 | sources: 7 | - https://github.com/nais/azurerator/tree/master/charts 8 | -------------------------------------------------------------------------------- /pkg/util/strings/strings.go: -------------------------------------------------------------------------------- 1 | package strings 2 | 3 | func RemoveDuplicates(list []string) []string { 4 | seen := make(map[string]struct{}, 0) 5 | filtered := make([]string, 0) 6 | 7 | for _, key := range list { 8 | if _, found := seen[key]; !found { 9 | seen[key] = struct{}{} 10 | filtered = append(filtered, key) 11 | } 12 | } 13 | return filtered 14 | } 15 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | go = "1.25.5" 3 | helm = "3.16.2" 4 | 5 | [tasks.actions-update] 6 | description = "Upgrade all github actions to latest version satisfying their version tag" 7 | run = "go tool ratchet update .github/workflows/*.yaml" 8 | 9 | [tasks.actions-upgrade] 10 | description = "Upgrade all github actions to latest" 11 | run = "go tool ratchet upgrade .github/workflows/*.yaml" 12 | -------------------------------------------------------------------------------- /pkg/azure/client/preauthorizedapp/list.go: -------------------------------------------------------------------------------- 1 | package preauthorizedapp 2 | 3 | import ( 4 | msgraph "github.com/nais/msgraph.go/v1.0" 5 | 6 | "github.com/nais/azureator/pkg/azure/resource" 7 | ) 8 | 9 | type List []msgraph.PreAuthorizedApplication 10 | 11 | func (l List) HasResource(resource resource.Resource) bool { 12 | for _, app := range l { 13 | if *app.AppID == resource.ClientId { 14 | return true 15 | } 16 | } 17 | return false 18 | } 19 | -------------------------------------------------------------------------------- /pkg/azure/runtimeclient.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | msgraph "github.com/nais/msgraph.go/v1.0" 8 | 9 | "github.com/nais/azureator/pkg/config" 10 | ) 11 | 12 | type RuntimeClient interface { 13 | Config() *config.AzureConfig 14 | GraphClient() *msgraph.GraphServiceRequestBuilder 15 | HttpClient() *http.Client 16 | 17 | DelayIntervalBetweenModifications() time.Duration 18 | MaxNumberOfPagesToFetch() int 19 | } 20 | -------------------------------------------------------------------------------- /pkg/customresources/accesspolicyrule.go: -------------------------------------------------------------------------------- 1 | package customresources 2 | 3 | import ( 4 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 5 | "github.com/nais/liberator/pkg/kubernetes" 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | ) 8 | 9 | func GetUniqueName(in v1.AccessPolicyRule) string { 10 | return kubernetes.UniformResourceName(&metav1.ObjectMeta{ 11 | Name: in.Application, 12 | Namespace: in.Namespace, 13 | }, in.Cluster) 14 | } 15 | -------------------------------------------------------------------------------- /pkg/labels/labels.go: -------------------------------------------------------------------------------- 1 | package labels 2 | 3 | import ( 4 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 5 | ) 6 | 7 | const ( 8 | AppLabelKey string = "app" 9 | TypeLabelKey string = "type" 10 | TypeLabelValue string = "azurerator.nais.io" 11 | ) 12 | 13 | func Labels(instance *v1.AzureAdApplication) map[string]string { 14 | return map[string]string{ 15 | AppLabelKey: instance.GetName(), 16 | TypeLabelKey: TypeLabelValue, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /charts/.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 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /pkg/azure/fake/azureopenidconfig.go: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import ( 4 | "github.com/nais/azureator/pkg/config" 5 | ) 6 | 7 | func AzureOpenIdConfig() config.AzureOpenIdConfig { 8 | return config.AzureOpenIdConfig{ 9 | WellKnownEndpoint: "https://azure-issuer/.well-known/openid-configuration", 10 | Issuer: "https://azure-issuer/", 11 | TokenEndpoint: "https://azure-issuer/token", 12 | JwksURI: "https://azure-issuer/keys", 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /charts/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | {{- if .Values.global.google.federatedAuth | default .Values.google.federatedAuth }} 6 | annotations: 7 | iam.gke.io/gcp-service-account: "azurerator@{{ .Values.global.google.projectID | default .Values.google.projectID }}.iam.gserviceaccount.com" 8 | {{ end }} 9 | labels: 10 | {{- include "azurerator.labels" . | nindent 4 }} 11 | name: {{ include "azurerator.fullname" . }} 12 | -------------------------------------------------------------------------------- /pkg/azure/result/preauthorizedapp.go: -------------------------------------------------------------------------------- 1 | package result 2 | 3 | import ( 4 | "github.com/nais/azureator/pkg/azure/resource" 5 | ) 6 | 7 | type PreAuthorizedApps struct { 8 | // Valid is the list of apps that either are or can be assigned to an application in Azure AD. 9 | Valid []resource.Resource `json:"valid"` 10 | // Invalid is the list of apps that cannot be assigned to the application in Azure AD (e.g. apps that do not exist). 11 | Invalid []resource.Resource `json:"invalid"` 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | bin 9 | 10 | # Test binary, build with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Kubernetes Generated files - skip generated files, except for vendored files 17 | 18 | !vendor/**/zz_generated.* 19 | 20 | # editor and IDE paraphernalia 21 | .idea 22 | *.swp 23 | *.swo 24 | *~ 25 | 26 | azurerator.yaml 27 | -------------------------------------------------------------------------------- /charts/templates/topic.yaml: -------------------------------------------------------------------------------- 1 | {{ if .Values.global.kafka.topic | default .Values.kafka.topic }} 2 | --- 3 | apiVersion: kafka.nais.io/v1 4 | kind: Topic 5 | metadata: 6 | name: {{ include "azurerator.fullname" . }} 7 | labels: 8 | {{ include "azurerator.labels" . | nindent 4 }} 9 | spec: 10 | acl: 11 | - access: readwrite 12 | application: {{ include "azurerator.fullname" . }} 13 | team: {{ .Release.Namespace }} 14 | pool: {{ .Values.global.kafka.pool | default .Values.kafka.pool }} 15 | {{ end }} 16 | -------------------------------------------------------------------------------- /pkg/transaction/secrets/secrets.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "github.com/nais/liberator/pkg/kubernetes" 5 | 6 | "github.com/nais/azureator/pkg/azure/credentials" 7 | "github.com/nais/azureator/pkg/secrets" 8 | ) 9 | 10 | type Secrets struct { 11 | DataKeys secrets.SecretDataKeys 12 | KeyIDs credentials.KeyIDs 13 | LatestCredentials Credentials 14 | ManagedSecrets kubernetes.SecretLists 15 | } 16 | 17 | type Credentials struct { 18 | Set *credentials.Set 19 | Valid bool 20 | } 21 | -------------------------------------------------------------------------------- /pkg/util/strings/strings_test.go: -------------------------------------------------------------------------------- 1 | package strings_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/nais/azureator/pkg/util/strings" 9 | ) 10 | 11 | func TestRemoveDuplicates(t *testing.T) { 12 | list := []string{"some", "value", "some", "other", "value"} 13 | expected := []string{"some", "other", "value"} 14 | 15 | filtered := strings.RemoveDuplicates(list) 16 | 17 | assert.ElementsMatch(t, filtered, expected) 18 | assert.Len(t, filtered, len(expected)) 19 | } 20 | -------------------------------------------------------------------------------- /hack/resources/03-deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: Deployment 3 | apiVersion: apps/v1 4 | metadata: 5 | name: azurerator 6 | namespace: azurerator-system 7 | labels: 8 | app: azurerator 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: azurerator 14 | template: 15 | metadata: 16 | labels: 17 | app: azurerator 18 | spec: 19 | serviceAccountName: azurerator 20 | containers: 21 | - name: azurerator 22 | image: ghcr.io/nais/azurerator:latest 23 | -------------------------------------------------------------------------------- /pkg/util/crypto/keypair.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "fmt" 8 | ) 9 | 10 | type KeyPair struct { 11 | Private crypto.PrivateKey 12 | Public crypto.PublicKey 13 | } 14 | 15 | func NewRSAKeyPair() (KeyPair, error) { 16 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 17 | if err != nil { 18 | return KeyPair{}, fmt.Errorf("failed to generate RSA keypair: %w", err) 19 | } 20 | return KeyPair{ 21 | Private: privateKey, 22 | Public: privateKey.Public(), 23 | }, nil 24 | } 25 | -------------------------------------------------------------------------------- /pkg/azure/fake/msgraph/serviceprincipal.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/nais/msgraph.go/ptr" 6 | msgraph "github.com/nais/msgraph.go/v1.0" 7 | 8 | "github.com/nais/azureator/pkg/transaction" 9 | ) 10 | 11 | func ServicePrincipal(tx transaction.Transaction) msgraph.ServicePrincipal { 12 | id := uuid.New().String() 13 | return msgraph.ServicePrincipal{ 14 | DirectoryObject: msgraph.DirectoryObject{Entity: msgraph.Entity{ID: &id}}, 15 | DisplayName: ptr.String(tx.UniformResourceName), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "time" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | func ZapLogger() (*zap.Logger, error) { 12 | cfg := zap.NewProductionConfig() 13 | cfg.Level = zap.NewAtomicLevelAt(zap.DebugLevel) 14 | cfg.EncoderConfig.TimeKey = "timestamp" 15 | cfg.EncoderConfig.EncodeTime = zapcore.RFC3339NanoTimeEncoder 16 | return cfg.Build() 17 | } 18 | 19 | func SetupLogrus() { 20 | formatter := &log.JSONFormatter{ 21 | TimestampFormat: time.RFC3339Nano, 22 | } 23 | log.SetFormatter(formatter) 24 | log.SetLevel(log.DebugLevel) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/azure/client/directoryobject/owner.go: -------------------------------------------------------------------------------- 1 | package directoryobject 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | 7 | "github.com/nais/azureator/pkg/azure" 8 | msgraph "github.com/nais/msgraph.go/v1.0" 9 | ) 10 | 11 | type OwnerPayload struct { 12 | Content string `json:"@odata.id"` 13 | } 14 | 15 | func ToOwnerPayload(id azure.ServicePrincipalId) OwnerPayload { 16 | return OwnerPayload{ 17 | Content: fmt.Sprintf("https://graph.microsoft.com/v1.0/directoryObjects/%s", id), 18 | } 19 | } 20 | 21 | func ContainsOwner(owners []msgraph.DirectoryObject, id azure.ServicePrincipalId) bool { 22 | return slices.ContainsFunc(owners, func(obj msgraph.DirectoryObject) bool { 23 | return obj.ID != nil && *obj.ID == id 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/azure/fake/msgraph/application.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "github.com/nais/msgraph.go/ptr" 5 | msgraph "github.com/nais/msgraph.go/v1.0" 6 | 7 | "github.com/nais/azureator/pkg/azure/fake" 8 | "github.com/nais/azureator/pkg/transaction" 9 | ) 10 | 11 | func Application(tx transaction.Transaction) msgraph.Application { 12 | objectId := fake.GetOrGenerate(tx.Instance.GetObjectId()) 13 | clientId := fake.GetOrGenerate(tx.Instance.GetClientId()) 14 | 15 | return msgraph.Application{ 16 | DirectoryObject: msgraph.DirectoryObject{ 17 | Entity: msgraph.Entity{ID: ptr.String(objectId)}, 18 | }, 19 | DisplayName: ptr.String(tx.UniformResourceName), 20 | AppID: ptr.String(clientId), 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pkg/retry/retry.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/sethvargo/go-retry" 8 | ) 9 | 10 | type Backoff struct { 11 | b retry.Backoff 12 | } 13 | 14 | func RetryableError(err error) error { 15 | return retry.RetryableError(err) 16 | } 17 | 18 | func Fibonacci(base time.Duration) Backoff { 19 | if base <= 0 { 20 | base = 1 * time.Second 21 | } 22 | b := retry.NewFibonacci(base) 23 | 24 | return Backoff{ 25 | b: b, 26 | } 27 | } 28 | 29 | func (in Backoff) WithMaxDuration(timeout time.Duration) Backoff { 30 | in.b = retry.WithMaxDuration(timeout, in.b) 31 | return in 32 | } 33 | 34 | func (in Backoff) Do(ctx context.Context, f retry.RetryFunc) error { 35 | return retry.Do(ctx, in.b, f) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/transaction/options/tenant.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 5 | ) 6 | 7 | type TenantOptions struct { 8 | Ignore bool 9 | } 10 | 11 | func (b optionsBuilder) Tenant() TenantOptions { 12 | notAddressedToTenant := IsNotAddressedToTenant(b.instance, b.config.Azure.Tenant.Name, b.config.Validations.Tenant.Required) 13 | 14 | return TenantOptions{ 15 | Ignore: notAddressedToTenant, 16 | } 17 | } 18 | 19 | func IsNotAddressedToTenant(instance v1.AzureAdApplication, configuredTenant string, requireMatchingTenant bool) bool { 20 | tenant := instance.Spec.Tenant 21 | 22 | if len(tenant) > 0 { 23 | return tenant != configuredTenant 24 | } 25 | 26 | return requireMatchingTenant 27 | } 28 | -------------------------------------------------------------------------------- /pkg/azure/typealiases.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | // DisplayName is the display name for the Graph API Application resource 4 | type DisplayName = string 5 | 6 | // ClientId is the Client ID / Application ID for the Graph API Application resource 7 | type ClientId = string 8 | 9 | // ObjectId is the Object ID for the Graph API Application resource 10 | type ObjectId = string 11 | 12 | // ServicePrincipalId is the Object ID for the Graph API Service Principal resource 13 | type ServicePrincipalId = string 14 | 15 | // IdentifierUris is a list of unique Application ID URIs for the Graph API Application resource 16 | type IdentifierUris = []string 17 | 18 | // Filter is the Graph API OData query option for filtering results of a collection 19 | type Filter = string 20 | -------------------------------------------------------------------------------- /pkg/util/test/helpers.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func AssertContainsKeysWithNonEmptyValues(t *testing.T, a any, keys []string) { 12 | for _, key := range keys { 13 | assert.Containsf(t, a, key, "should contain key '%s'", key) 14 | } 15 | v := reflect.ValueOf(a) 16 | if v.Kind() == reflect.Map { 17 | for _, val := range v.MapKeys() { 18 | assert.NotEmpty(t, v.MapIndex(val).String()) 19 | } 20 | } 21 | assert.Lenf(t, a, len(keys), "should contain %v keys", len(keys)) 22 | } 23 | 24 | func AssertAllNotEmpty(t *testing.T, values []any) { 25 | for i, val := range values { 26 | assert.NotEmpty(t, val, fmt.Sprintf("%s (index %v) should not be empty", val, i)) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | k8s-io: 9 | patterns: 10 | - 'k8s.io/*' 11 | - 'sigs.k8s.io/controller-runtime' 12 | cooldown: 13 | default-days: 7 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "weekly" 18 | groups: 19 | gh-actions: 20 | patterns: 21 | - '*' 22 | cooldown: 23 | default-days: 7 24 | - package-ecosystem: "docker" 25 | directory: "/" 26 | schedule: 27 | interval: "weekly" 28 | groups: 29 | docker: 30 | patterns: 31 | - '*' 32 | cooldown: 33 | default-days: 7 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.25 AS builder 3 | 4 | ENV os "linux" 5 | ENV arch "amd64" 6 | 7 | COPY . /workspace 8 | WORKDIR /workspace 9 | 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 | # Build 14 | RUN CGO_ENABLED=0 GOOS=${os} GOARCH=${arch} GO111MODULE=on go build -a -installsuffix cgo -o azurerator cmd/azurerator/main.go 15 | 16 | # Use distroless as minimal base image to package the manager binary 17 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 18 | FROM gcr.io/distroless/static-debian12:nonroot 19 | WORKDIR / 20 | COPY --from=builder /workspace/azurerator /azurerator 21 | 22 | CMD ["/azurerator"] 23 | -------------------------------------------------------------------------------- /pkg/azure/util/strings.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/nais/azureator/pkg/azure" 9 | ) 10 | 11 | func MapFiltersToFilter(filters []azure.Filter) azure.Filter { 12 | if len(filters) > 0 { 13 | return strings.Join(filters[:], " ") 14 | } else { 15 | return "" 16 | } 17 | } 18 | 19 | func FilterByName(name azure.DisplayName) azure.Filter { 20 | return fmt.Sprintf("displayName eq '%s'", name) 21 | } 22 | 23 | func FilterByAppId(clientId azure.ClientId) azure.Filter { 24 | return fmt.Sprintf("appId eq '%s'", clientId) 25 | } 26 | 27 | func FilterByClientId(clientId azure.ClientId) azure.Filter { 28 | return fmt.Sprintf("clientId eq '%s'", clientId) 29 | } 30 | 31 | func DisplayName(t time.Time) azure.DisplayName { 32 | return fmt.Sprintf("%s-%s", azure.AzureratorPrefix, t.UTC().Format(time.RFC3339)) 33 | } 34 | -------------------------------------------------------------------------------- /pkg/azure/client/application/requiredresourceaccess/requiredresourceaccess_test.go: -------------------------------------------------------------------------------- 1 | package requiredresourceaccess 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestRequiredResourceAccess_microsoftGraph(t *testing.T) { 11 | a := NewRequiredResourceAccess().MicrosoftGraph() 12 | j, _ := json.Marshal(a) 13 | 14 | assert.JSONEq(t, ` 15 | { 16 | "resourceAppId": "00000003-0000-0000-c000-000000000000", 17 | "resourceAccess": [ 18 | { 19 | "id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d", 20 | "type": "Scope" 21 | }, 22 | { 23 | "id": "37f7f235-527c-4136-accd-4a02d197296e", 24 | "type": "Scope" 25 | }, 26 | { 27 | "id": "bc024368-1153-4739-b217-4326f2e966d0", 28 | "type": "Scope" 29 | } 30 | ] 31 | } 32 | `, string(j)) 33 | } 34 | -------------------------------------------------------------------------------- /pkg/azure/client/preauthorizedapp/list_test.go: -------------------------------------------------------------------------------- 1 | package preauthorizedapp 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nais/msgraph.go/ptr" 7 | msgraph "github.com/nais/msgraph.go/v1.0" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/nais/azureator/pkg/azure/permissions" 11 | "github.com/nais/azureator/pkg/azure/resource" 12 | ) 13 | 14 | func TestList_HasResource(t *testing.T) { 15 | preAuthApps := List([]msgraph.PreAuthorizedApplication{ 16 | { 17 | AppID: ptr.String("app-1"), 18 | DelegatedPermissionIDs: []string{ 19 | permissions.DefaultPermissionScopeId, 20 | }, 21 | }, 22 | }) 23 | 24 | expected := resource.Resource{ 25 | ClientId: "app-1", 26 | } 27 | assert.True(t, preAuthApps.HasResource(expected)) 28 | 29 | expected = resource.Resource{ 30 | ClientId: "app-2", 31 | } 32 | assert.False(t, preAuthApps.HasResource(expected)) 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yaml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b # ratchet:dependabot/fetch-metadata@v2 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | if: steps.metadata.outputs.update-type != 'version-update:semver-major' 20 | run: gh pr merge --auto --squash "$PR_URL" 21 | env: 22 | PR_URL: ${{github.event.pull_request.html_url}} 23 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 24 | -------------------------------------------------------------------------------- /pkg/transaction/options/options.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 5 | 6 | "github.com/nais/azureator/pkg/config" 7 | "github.com/nais/azureator/pkg/transaction/secrets" 8 | ) 9 | 10 | type TransactionOptions struct { 11 | Tenant TenantOptions 12 | Process ProcessOptions 13 | } 14 | 15 | type optionsBuilder struct { 16 | instance v1.AzureAdApplication 17 | config config.Config 18 | secrets secrets.Secrets 19 | } 20 | 21 | func NewOptions(instance v1.AzureAdApplication, cfg config.Config, secrets secrets.Secrets) (TransactionOptions, error) { 22 | builder := optionsBuilder{ 23 | instance: instance, 24 | config: cfg, 25 | secrets: secrets, 26 | } 27 | 28 | process, err := builder.Process() 29 | if err != nil { 30 | return TransactionOptions{}, err 31 | } 32 | 33 | return TransactionOptions{ 34 | Process: process, 35 | Tenant: builder.Tenant(), 36 | }, nil 37 | } 38 | -------------------------------------------------------------------------------- /pkg/azure/result/application.go: -------------------------------------------------------------------------------- 1 | package result 2 | 3 | import ( 4 | "github.com/nais/azureator/pkg/azure/permissions" 5 | ) 6 | 7 | type Application struct { 8 | ClientId string `json:"clientId"` 9 | ObjectId string `json:"objectId"` 10 | ServicePrincipalId string `json:"servicePrincipalId"` 11 | Permissions permissions.Permissions `json:"permissions"` 12 | PreAuthorizedApps PreAuthorizedApps `json:"preAuthorizedApps"` 13 | Tenant string `json:"tenant"` 14 | Result Operation `json:"result"` 15 | } 16 | 17 | func (a Application) IsNotModified() bool { 18 | return a.Result == OperationNotModified 19 | } 20 | 21 | func (a Application) IsCreated() bool { 22 | return a.Result == OperationCreated 23 | } 24 | 25 | func (a Application) IsUpdated() bool { 26 | return a.Result == OperationUpdated 27 | } 28 | 29 | func (a Application) IsModified() bool { 30 | return a.IsCreated() || a.IsUpdated() 31 | } 32 | -------------------------------------------------------------------------------- /config/samples/azureadapplication.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: nais.io/v1 3 | kind: AzureAdApplication 4 | metadata: 5 | name: myapp 6 | namespace: myteam 7 | labels: 8 | team: myteam 9 | spec: 10 | # required 11 | secretName: azuread-myapp 12 | # everything below is optional 13 | allowAllUsers: false 14 | claims: 15 | groups: 16 | - id: "00000000-0000-0000-0000-000000000000" 17 | groupMembershipClaims: "ApplicationGroup" 18 | logoutUrl: "https://localhost:3000/oauth2/logout" 19 | preAuthorizedApplications: 20 | - application: myapp2 21 | cluster: minikube 22 | namespace: myteam 23 | - application: some-other-app 24 | cluster: test-cluster 25 | namespace: myteam 26 | permissions: 27 | roles: 28 | - "my-custom-role" 29 | scopes: 30 | - "my-scope-scope" 31 | replyUrls: 32 | - url: "http://localhost:3000/oauth2/callback" 33 | singlePageApplication: false 34 | secretKeyPrefix: "" # defaults to 'AZURE' if empty or undefined 35 | secretProtected: false 36 | tenant: local.test 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 NAV 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/azure/credentials/credentials.go: -------------------------------------------------------------------------------- 1 | package credentials 2 | 3 | import ( 4 | msgraph "github.com/nais/msgraph.go/v1.0" 5 | 6 | "github.com/nais/azureator/pkg/util/crypto" 7 | ) 8 | 9 | type Set struct { 10 | Current Credentials `json:"current"` 11 | Next Credentials `json:"next"` 12 | } 13 | 14 | type KeyIDs struct { 15 | Used KeyID `json:"used"` 16 | Unused KeyID `json:"unused"` 17 | } 18 | 19 | type KeyID struct { 20 | Certificate []string `json:"certificate"` 21 | Password []string `json:"password"` 22 | } 23 | 24 | type Credentials struct { 25 | Certificate Certificate `json:"certificate"` 26 | Password Password `json:"password"` 27 | } 28 | 29 | type Certificate struct { 30 | KeyId string `json:"keyId"` 31 | Jwk crypto.Jwk `json:"jwk"` 32 | } 33 | 34 | type Password struct { 35 | KeyId string `json:"keyId"` 36 | ClientSecret string `json:"clientSecret"` 37 | } 38 | 39 | type AddedKeyCredentialSet struct { 40 | Current AddedKeyCredential 41 | Next AddedKeyCredential 42 | } 43 | 44 | type AddedKeyCredential struct { 45 | KeyCredential msgraph.KeyCredential 46 | Jwk crypto.Jwk 47 | } 48 | -------------------------------------------------------------------------------- /pkg/fixtures/azureadapplication.go: -------------------------------------------------------------------------------- 1 | package fixtures 2 | 3 | import ( 4 | nais_io_v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | func MinimalApplication() *nais_io_v1.AzureAdApplication { 9 | now := metav1.Now() 10 | return &nais_io_v1.AzureAdApplication{ 11 | ObjectMeta: metav1.ObjectMeta{ 12 | Name: "test-app", 13 | Namespace: "test-namespace", 14 | }, 15 | Spec: nais_io_v1.AzureAdApplicationSpec{ 16 | ReplyUrls: nil, 17 | PreAuthorizedApplications: nil, 18 | LogoutUrl: "test", 19 | SecretName: "test", 20 | }, 21 | Status: nais_io_v1.AzureAdApplicationStatus{ 22 | PasswordKeyIds: []string{"test"}, 23 | CertificateKeyIds: []string{"test"}, 24 | ClientId: "test", 25 | ObjectId: "test", 26 | ServicePrincipalId: "test", 27 | SynchronizationHash: "b85f1aaff45fcfc2", 28 | SynchronizationSecretName: "test", 29 | SynchronizationSecretRotationTime: &now, 30 | }, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /charts/templates/netpol.yaml: -------------------------------------------------------------------------------- 1 | {{ if .Values.global.networkPolicy.enabled | default .Values.networkPolicy.enabled }} 2 | apiVersion: networking.k8s.io/v1 3 | kind: NetworkPolicy 4 | metadata: 5 | name: {{ include "azurerator.fullname" . }}-apiserver 6 | labels: 7 | {{ include "azurerator.labels" . | nindent 4 }} 8 | spec: 9 | egress: 10 | - to: 11 | - ipBlock: 12 | cidr: {{ .Values.global.networkPolicy.apiServerCIDR | default .Values.networkPolicy.apiServerCIDR }} 13 | - ports: 14 | - port: 443 15 | protocol: TCP 16 | to: 17 | # selected microsoft login ranges (https://learn.microsoft.com/en-us/microsoft-365/enterprise/urls-and-ip-address-ranges?view=o365-worldwide#microsoft-365-common-and-office-online) 18 | - ipBlock: 19 | cidr: 20.190.128.0/18 20 | - ipBlock: 21 | cidr: 40.126.0.0/18 22 | - ipBlock: 23 | cidr: 20.20.32.0/19 24 | - ipBlock: 25 | cidr: 20.231.128.0/19 26 | podSelector: 27 | matchLabels: 28 | {{ include "azurerator.selectorLabels" . | nindent 6 }} 29 | policyTypes: 30 | - Egress 31 | {{ end }} 32 | -------------------------------------------------------------------------------- /pkg/transaction/transaction.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "context" 5 | 6 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 7 | msgraph "github.com/nais/msgraph.go/v1.0" 8 | log "github.com/sirupsen/logrus" 9 | 10 | "github.com/nais/azureator/pkg/transaction/options" 11 | "github.com/nais/azureator/pkg/transaction/secrets" 12 | ) 13 | 14 | type Transaction struct { 15 | Ctx context.Context 16 | ClusterName string 17 | ExistsInAzure bool 18 | Instance *v1.AzureAdApplication 19 | Logger log.Entry 20 | Options options.TransactionOptions 21 | Secrets secrets.Secrets 22 | ID string 23 | UniformResourceName string 24 | } 25 | 26 | func (t Transaction) UpdateWithApplicationIDs(application msgraph.Application) Transaction { 27 | t.Instance.Status.ClientId = *application.AppID 28 | t.Instance.Status.ObjectId = *application.ID 29 | return t 30 | } 31 | 32 | func (t Transaction) UpdateWithServicePrincipalID(servicePrincipal msgraph.ServicePrincipal) Transaction { 33 | t.Instance.Status.ServicePrincipalId = *servicePrincipal.ID 34 | return t 35 | } 36 | -------------------------------------------------------------------------------- /charts/templates/application.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: nais.io/v1alpha1 3 | kind: Application 4 | metadata: 5 | name: {{ include "azurerator.fullname" . }} 6 | labels: 7 | team: nais 8 | {{ include "azurerator.labels" . | nindent 4 }} 9 | spec: 10 | image: "{{ .Values.global.image.repository | default .Values.image.repository }}:{{ .Values.global.image.tag | default .Values.image.tag }}" 11 | port: 8080 12 | liveness: 13 | path: /metrics 14 | readiness: 15 | path: /metrics 16 | resources: 17 | limits: 18 | memory: 2Gi 19 | requests: 20 | memory: 512Mi 21 | replicas: 22 | min: 1 23 | max: 1 24 | prometheus: 25 | enabled: true 26 | path: /metrics 27 | filesFrom: 28 | - secret: {{ include "azurerator.fullname" . }}-env 29 | mountPath: /etc/azurerator 30 | accessPolicy: 31 | inbound: 32 | rules: 33 | - application: prometheus 34 | {{- if .Values.global.kafka.application | default .Values.kafka.application }} 35 | kafka: 36 | pool: "{{ .Values.global.kafka.pool | default .Values.kafka.pool }}" 37 | {{ end }} 38 | webproxy: {{ .Values.global.webproxy | default .Values.webproxy }} 39 | skipCaBundle: true 40 | -------------------------------------------------------------------------------- /docs/sequence.puml: -------------------------------------------------------------------------------- 1 | @startuml component 2 | title Azurerator Sequence Flow 3 | skinparam maxMessageSize 300 4 | autonumber 5 | 6 | actor developer as "Developer" 7 | control azurerator as "Azurerator" 8 | 9 | box "Cluster resources" 10 | participant AzureAdApplication 11 | participant Secret 12 | end box 13 | 14 | participant azuread as "Azure AD" 15 | 16 | ==On create / update== 17 | developer -> AzureAdApplication: Apply config 18 | 19 | loop forever 20 | azurerator -> AzureAdApplication: watch for updates 21 | end 22 | 23 | azurerator -> azuread: check if application exists 24 | azurerator -> azuread: register / update application 25 | azurerator -> azurerator: generate new set of credentials 26 | azurerator -> azuread: register new credentials 27 | 28 | group application already exists in AAD 29 | azurerator -> Secret: fetch existing credentials 30 | azurerator -> azuread: invalidate older, non-used credentials 31 | end 32 | 33 | azurerator -> AzureAdApplication: update status subresource 34 | azurerator -> Secret: inject credentials and metadata 35 | 36 | ==On deletion== 37 | developer -> AzureAdApplication: delete 38 | azurerator -> Secret: delete 39 | azurerator -> azuread: delete 40 | 41 | @enduml 42 | -------------------------------------------------------------------------------- /pkg/azure/client.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | import ( 4 | "github.com/nais/msgraph.go/v1.0" 5 | 6 | "github.com/nais/azureator/pkg/azure/credentials" 7 | "github.com/nais/azureator/pkg/azure/result" 8 | "github.com/nais/azureator/pkg/transaction" 9 | ) 10 | 11 | type Client interface { 12 | Create(tx transaction.Transaction) (*result.Application, error) 13 | Delete(tx transaction.Transaction) error 14 | Exists(tx transaction.Transaction) (*msgraph.Application, bool, error) 15 | Get(tx transaction.Transaction) (msgraph.Application, error) 16 | Update(tx transaction.Transaction) (*result.Application, error) 17 | 18 | Credentials() Credentials 19 | 20 | GetPreAuthorizedApps(tx transaction.Transaction) (*result.PreAuthorizedApps, error) 21 | GetServicePrincipal(tx transaction.Transaction) (msgraph.ServicePrincipal, error) 22 | } 23 | 24 | type Credentials interface { 25 | Add(tx transaction.Transaction) (credentials.Set, error) 26 | DeleteExpired(tx transaction.Transaction) error 27 | DeleteUnused(tx transaction.Transaction) error 28 | Purge(tx transaction.Transaction) error 29 | Rotate(tx transaction.Transaction) (credentials.Set, error) 30 | Validate(tx transaction.Transaction, existing credentials.Set) (bool, error) 31 | } 32 | -------------------------------------------------------------------------------- /pkg/event/event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | ) 9 | 10 | type Event struct { 11 | ID string `json:"@id"` 12 | Name Name `json:"@event_name"` 13 | Application Application `json:"application"` 14 | } 15 | 16 | type Name string 17 | 18 | const ( 19 | Created Name = "Created" 20 | ) 21 | 22 | type Application struct { 23 | Name string `json:"name"` 24 | Namespace string `json:"namespace"` 25 | Cluster string `json:"cluster"` 26 | } 27 | 28 | func (a Application) String() string { 29 | return fmt.Sprintf("%s:%s:%s", a.Cluster, a.Namespace, a.Name) 30 | } 31 | 32 | func New(ID string, eventName Name, app metav1.Object, clusterName string) Event { 33 | application := Application{ 34 | Name: app.GetName(), 35 | Namespace: app.GetNamespace(), 36 | Cluster: clusterName, 37 | } 38 | 39 | return Event{ID: ID, Name: eventName, Application: application} 40 | } 41 | 42 | func (e Event) Marshal() ([]byte, error) { 43 | return json.Marshal(e) 44 | } 45 | 46 | func (e Event) IsCreated() bool { 47 | return e.Name == Created 48 | } 49 | 50 | func (e Event) String() string { 51 | return fmt.Sprintf("%s (%s)", e.Name, e.ID) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/azure/client/application/requiredresourceaccess/requiredresourceaccess.go: -------------------------------------------------------------------------------- 1 | package requiredresourceaccess 2 | 3 | import ( 4 | "github.com/nais/msgraph.go/ptr" 5 | msgraph "github.com/nais/msgraph.go/v1.0" 6 | ) 7 | 8 | type RequiredResourceAccess interface { 9 | MicrosoftGraph() msgraph.RequiredResourceAccess 10 | } 11 | 12 | type requiredResourceAccess struct{} 13 | 14 | func NewRequiredResourceAccess() RequiredResourceAccess { 15 | return requiredResourceAccess{} 16 | } 17 | 18 | // Access to Microsoft Graph API 19 | func (r requiredResourceAccess) MicrosoftGraph() msgraph.RequiredResourceAccess { 20 | userReadScopeId := msgraph.UUID("e1fe6dd8-ba31-4d61-89e7-88639da4683d") // User.Read 21 | openidScopeId := msgraph.UUID("37f7f235-527c-4136-accd-4a02d197296e") // openid 22 | groupMemberReadAll := msgraph.UUID("bc024368-1153-4739-b217-4326f2e966d0") // GroupMember.Read.All 23 | return msgraph.RequiredResourceAccess{ 24 | ResourceAppID: ptr.String("00000003-0000-0000-c000-000000000000"), 25 | ResourceAccess: []msgraph.ResourceAccess{ 26 | { 27 | ID: &userReadScopeId, 28 | Type: ptr.String("Scope"), 29 | }, 30 | { 31 | ID: &openidScopeId, 32 | Type: ptr.String("Scope"), 33 | }, 34 | { 35 | ID: &groupMemberReadAll, 36 | Type: ptr.String("Scope"), 37 | }, 38 | }, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/azure/permissions/const.go: -------------------------------------------------------------------------------- 1 | package permissions 2 | 3 | const ( 4 | // DefaultPermissionScopeValue is the OAuth2 permission scope that the web API application exposes to client applications 5 | DefaultPermissionScopeValue string = "defaultaccess" 6 | // DefaultPermissionScopeId is a unique (per application) ID for the default permission access scope. 7 | DefaultPermissionScopeId string = "00000000-1337-d34d-b33f-000000000000" 8 | // DefaultScopeType denotes the default scope type to be set for all the application's permission scopes. 9 | DefaultScopeType string = "Admin" 10 | 11 | // DefaultAppRoleValue is the default AppRole that the web API application can assign to client applications. 12 | DefaultAppRoleValue string = "access_as_application" 13 | // DefaultAppRoleId is the unique (per application) ID for the default AppRole. 14 | DefaultAppRoleId string = "00000001-abcd-9001-0000-000000000000" 15 | 16 | // DefaultGroupRoleValue is the default AppRole for Groups 17 | DefaultGroupRoleValue string = "defaultrole" 18 | // DefaultGroupRoleId is the ID that denotes that the group should be assigned to the application without any special 19 | // AppRole: https://docs.microsoft.com/en-us/graph/api/group-post-approleassignments?view=graph-rest-1.0&tabs=http#request-body 20 | DefaultGroupRoleId string = "00000000-0000-0000-0000-000000000000" 21 | ) 22 | -------------------------------------------------------------------------------- /charts/templates/alert.yaml: -------------------------------------------------------------------------------- 1 | {{ if .Values.global.alerts.enabled | default .Values.alerts.enabled }} 2 | --- 3 | apiVersion: monitoring.coreos.com/v1 4 | kind: PrometheusRule 5 | metadata: 6 | name: {{ include "azurerator.fullname" . }}-alerts 7 | labels: 8 | {{ include "azurerator.labels" . | nindent 4 }} 9 | spec: 10 | groups: 11 | - name: "azurerator" 12 | rules: 13 | - alert: {{ include "azurerator.fullname" . }} failed provisioning clients 14 | expr: sum(increase(azureadapp_failed_processing_count{app="{{ include "azurerator.fullname" . }}"}[5m])) > {{ .Values.global.alerts.failedProcessingThreshold | default .Values.alerts.failedProcessingThreshold }} 15 | for: 5m 16 | annotations: 17 | summary: {{ include "azurerator.fullname" . }} has failed processing clients for longer than usual 18 | consequence: Applications that have spec.azure.application enabled will not start up as they are dependant on a secret created by Azurerator 19 | action: | 20 | * Check the logs: `kubectl logs -n {{ .Release.Namespace }} deploy/{{ include "azurerator.fullname" . }}`" 21 | * Check the Azure Status page: 22 | labels: 23 | severity: critical 24 | namespace: {{ .Release.Namespace }} 25 | {{ end }} 26 | -------------------------------------------------------------------------------- /pkg/customresources/azureadapplication.go: -------------------------------------------------------------------------------- 1 | package customresources 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | nais_io_v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 8 | 9 | "github.com/nais/azureator/pkg/annotations" 10 | ) 11 | 12 | func IsHashChanged(in *nais_io_v1.AzureAdApplication) (bool, error) { 13 | newHash, err := in.Hash() 14 | if err != nil { 15 | return false, fmt.Errorf("calculating application hash: %w", err) 16 | } 17 | return in.Status.SynchronizationHash != newHash, nil 18 | } 19 | 20 | func SecretNameChanged(in *nais_io_v1.AzureAdApplication) bool { 21 | return in.Status.SynchronizationSecretName != in.Spec.SecretName 22 | } 23 | 24 | func HasExpiredSecrets(in *nais_io_v1.AzureAdApplication, maxSecretAge time.Duration) bool { 25 | if in.Status.SynchronizationSecretRotationTime == nil || in.Spec.SecretProtected { 26 | return false 27 | } 28 | 29 | lastRotationTime := *in.Status.SynchronizationSecretRotationTime 30 | diff := time.Since(lastRotationTime.Time) 31 | secretExpired := diff >= maxSecretAge 32 | 33 | return secretExpired 34 | } 35 | 36 | func HasResynchronizeAnnotation(in *nais_io_v1.AzureAdApplication) bool { 37 | _, found := annotations.HasAnnotation(in, annotations.ResynchronizeKey) 38 | return found 39 | } 40 | 41 | func HasRotateAnnotation(in *nais_io_v1.AzureAdApplication) bool { 42 | _, found := annotations.HasAnnotation(in, annotations.RotateKey) 43 | return found 44 | } 45 | -------------------------------------------------------------------------------- /pkg/azure/client/application/approle/result.go: -------------------------------------------------------------------------------- 1 | package approle 2 | 3 | import ( 4 | msgraph "github.com/nais/msgraph.go/v1.0" 5 | log "github.com/sirupsen/logrus" 6 | ) 7 | 8 | type Result interface { 9 | GetResult() []msgraph.AppRole 10 | Log(logger log.Entry) 11 | } 12 | 13 | type createResult struct { 14 | toCreate Map 15 | } 16 | 17 | func NewCreateResult(toCreate Map) Result { 18 | return createResult{toCreate: toCreate} 19 | } 20 | 21 | func (a createResult) GetResult() []msgraph.AppRole { 22 | return a.toCreate.ToSlice() 23 | } 24 | 25 | func (a createResult) Log(logger log.Entry) { 26 | a.toCreate.ToPermissionList().Log(logger, "creating desired roles") 27 | } 28 | 29 | type updateResult struct { 30 | toCreate Map 31 | toDisable Map 32 | unmodified Map 33 | result []msgraph.AppRole 34 | } 35 | 36 | func NewUpdateResult(toCreate Map, toDisable Map, unmodified Map, result []msgraph.AppRole) Result { 37 | return updateResult{ 38 | toCreate: toCreate, 39 | toDisable: toDisable, 40 | unmodified: unmodified, 41 | result: result, 42 | } 43 | } 44 | 45 | func (a updateResult) GetResult() []msgraph.AppRole { 46 | return a.result 47 | } 48 | 49 | func (a updateResult) Log(logger log.Entry) { 50 | a.toCreate.ToPermissionList().Log(logger, "creating desired roles") 51 | a.toDisable.ToPermissionList().Log(logger, "disabling non-desired roles") 52 | a.unmodified.ToPermissionList().Log(logger, "unmodified roles") 53 | } 54 | -------------------------------------------------------------------------------- /pkg/kafka/producer.go: -------------------------------------------------------------------------------- 1 | package kafka 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/IBM/sarama" 10 | log "github.com/sirupsen/logrus" 11 | 12 | "github.com/nais/azureator/pkg/config" 13 | "github.com/nais/azureator/pkg/event" 14 | ) 15 | 16 | type Producer struct { 17 | producer sarama.SyncProducer 18 | topic string 19 | } 20 | 21 | func NewProducer(config config.Config, tlsConfig *tls.Config, logger *log.Logger) (*Producer, error) { 22 | cfg := sarama.NewConfig() 23 | cfg.Net.TLS.Enable = true 24 | cfg.Net.TLS.Config = tlsConfig 25 | cfg.Producer.RequiredAcks = sarama.WaitForAll 26 | cfg.Producer.Return.Errors = true 27 | cfg.Producer.Return.Successes = true 28 | cfg.ClientID, _ = os.Hostname() 29 | sarama.Logger = logger 30 | 31 | syncProducer, err := sarama.NewSyncProducer(config.Kafka.Brokers, cfg) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return &Producer{ 37 | producer: syncProducer, 38 | topic: config.Kafka.Topic, 39 | }, nil 40 | } 41 | 42 | func (p *Producer) Send(e event.Event) (int64, error) { 43 | message, err := e.Marshal() 44 | if err != nil { 45 | return -1, fmt.Errorf("marshalling event: %w", err) 46 | } 47 | 48 | producerMessage := &sarama.ProducerMessage{ 49 | Topic: p.topic, 50 | Value: sarama.ByteEncoder(message), 51 | Timestamp: time.Now(), 52 | } 53 | _, offset, err := p.producer.SendMessage(producerMessage) 54 | return offset, err 55 | } 56 | -------------------------------------------------------------------------------- /pkg/azure/client/application/permissionscope/result.go: -------------------------------------------------------------------------------- 1 | package permissionscope 2 | 3 | import ( 4 | msgraph "github.com/nais/msgraph.go/v1.0" 5 | log "github.com/sirupsen/logrus" 6 | ) 7 | 8 | type Result interface { 9 | GetResult() []msgraph.PermissionScope 10 | Log(logger log.Entry) 11 | } 12 | 13 | type createResult struct { 14 | toCreate Map 15 | } 16 | 17 | func NewCreateResult(toCreate Map) Result { 18 | return createResult{toCreate: toCreate} 19 | } 20 | 21 | func (a createResult) GetResult() []msgraph.PermissionScope { 22 | return a.toCreate.ToSlice() 23 | } 24 | 25 | func (a createResult) Log(logger log.Entry) { 26 | a.toCreate.ToPermissionList().Log(logger, "creating desired scopes") 27 | } 28 | 29 | type updateResult struct { 30 | toCreate Map 31 | toDisable Map 32 | unmodified Map 33 | result []msgraph.PermissionScope 34 | } 35 | 36 | func NewUpdateResult(toCreate Map, toDisable Map, unmodified Map, result []msgraph.PermissionScope) Result { 37 | return updateResult{ 38 | toCreate: toCreate, 39 | toDisable: toDisable, 40 | unmodified: unmodified, 41 | result: result, 42 | } 43 | } 44 | 45 | func (a updateResult) GetResult() []msgraph.PermissionScope { 46 | return a.result 47 | } 48 | 49 | func (a updateResult) Log(logger log.Entry) { 50 | a.toCreate.ToPermissionList().Log(logger, "creating desired scopes") 51 | a.toDisable.ToPermissionList().Log(logger, "disabling non-desired scopes") 52 | a.unmodified.ToPermissionList().Log(logger, "unmodified scopes") 53 | } 54 | -------------------------------------------------------------------------------- /hack/resources/02-rbac.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: ClusterRole 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | metadata: 5 | labels: 6 | app: azurerator 7 | name: azurerator 8 | rules: 9 | - apiGroups: 10 | - nais.io 11 | resources: 12 | - azureadapplications 13 | - azureadapplications/status 14 | verbs: 15 | - get 16 | - list 17 | - watch 18 | - create 19 | - update 20 | - apiGroups: 21 | - "" 22 | resources: 23 | - secrets 24 | - events 25 | verbs: 26 | - get 27 | - list 28 | - watch 29 | - create 30 | - delete 31 | - update 32 | - patch 33 | - apiGroups: 34 | - "" 35 | resources: 36 | - pods 37 | - namespaces 38 | verbs: 39 | - list 40 | - get 41 | - watch 42 | - apiGroups: 43 | - apps 44 | resources: 45 | - replicasets 46 | verbs: 47 | - list 48 | - get 49 | - watch 50 | - apiGroups: 51 | - coordination.k8s.io 52 | resources: 53 | - leases 54 | verbs: 55 | - get 56 | - list 57 | - watch 58 | - create 59 | - update 60 | - patch 61 | 62 | --- 63 | kind: ClusterRoleBinding 64 | apiVersion: rbac.authorization.k8s.io/v1 65 | metadata: 66 | labels: 67 | app: azurerator 68 | name: azurerator 69 | roleRef: 70 | apiGroup: rbac.authorization.k8s.io 71 | kind: ClusterRole 72 | name: azurerator 73 | subjects: 74 | - kind: ServiceAccount 75 | name: azurerator 76 | namespace: azurerator-system 77 | -------------------------------------------------------------------------------- /pkg/azure/client/application/approle/approles.go: -------------------------------------------------------------------------------- 1 | package approle 2 | 3 | import ( 4 | msgraph "github.com/nais/msgraph.go/v1.0" 5 | 6 | "github.com/nais/azureator/pkg/azure/permissions" 7 | ) 8 | 9 | type AppRoles interface { 10 | DescribeCreate(desired permissions.Permissions) Result 11 | DescribeUpdate(desired permissions.Permissions, existing []msgraph.AppRole) Result 12 | } 13 | 14 | type appRoles struct{} 15 | 16 | func NewAppRoles() AppRoles { 17 | return appRoles{} 18 | } 19 | 20 | // DescribeCreate returns a slice describing the desired msgraph.AppRole to be created without actually creating them. 21 | func (a appRoles) DescribeCreate(desired permissions.Permissions) Result { 22 | existingSet := make(Map) 23 | return NewCreateResult(existingSet.ToCreate(desired)) 24 | } 25 | 26 | // DescribeUpdate returns a slice describing the desired state of both new (if any) and existing msgraph.AppRole, i.e: 27 | // 1) add any non-existing, desired roles. 28 | // 2) disable existing, non-desired roles. 29 | // It does not perform any modifying operations on the remote state in Azure AD. 30 | func (a appRoles) DescribeUpdate(desired permissions.Permissions, existing []msgraph.AppRole) Result { 31 | result := make([]msgraph.AppRole, 0) 32 | 33 | existingSet := ToMap(existing) 34 | 35 | toCreate := existingSet.ToCreate(desired) 36 | toDisable := existingSet.ToDisable(desired) 37 | unmodified := existingSet.Unmodified(toCreate, toDisable) 38 | 39 | result = append(result, unmodified.ToSlice()...) 40 | result = append(result, toCreate.ToSlice()...) 41 | result = append(result, toDisable.ToSlice()...) 42 | result = EnsureDefaultAppRoleIsEnabled(result) 43 | return NewUpdateResult(toCreate, toDisable, unmodified, result) 44 | } 45 | -------------------------------------------------------------------------------- /charts/Feature.yaml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - allOf: 3 | - nais-crds 4 | - naiserator 5 | - reloader 6 | environmentKinds: 7 | - tenant 8 | values: 9 | azure.clientID: 10 | required: true 11 | config: 12 | type: string 13 | azure.permissionGrantResourceID: 14 | description: Object ID for Microsoft Graph application within AAD tenant, needed for pre-approval of delegated permissions. 15 | displayName: Microsoft Graph Object ID 16 | required: true 17 | config: 18 | type: string 19 | azure.tenant.id: 20 | required: true 21 | config: 22 | type: string 23 | azure.tenant.name: 24 | description: Alias used to identify the tenant, such as the primary domain that is configured for the Azure AD tenant. 25 | required: true 26 | config: 27 | type: string 28 | clusterName: 29 | displayName: Cluster name 30 | computed: 31 | template: '"{{.Env.name}}"' 32 | config: 33 | type: string 34 | features.groupsAssignment.allUsersGroupIDs: 35 | description: Default set of Azure AD group object IDs to be assigned to applications if all users should have access. 36 | displayName: All users group IDs 37 | required: true 38 | config: 39 | type: string_array 40 | google.federatedAuth: 41 | description: Enable client authentication using federated Google credentials 42 | computed: 43 | template: "true" 44 | google.projectID: 45 | computed: 46 | template: '"{{.Env.project_id}}"' 47 | image.tag: 48 | config: 49 | type: string 50 | networkPolicy.apiServerCIDR: 51 | computed: 52 | template: '"{{ .Env.apiserver_endpoint }}/32"' 53 | networkPolicy.enabled: 54 | computed: 55 | template: "true" 56 | -------------------------------------------------------------------------------- /pkg/azure/client/application/approle/approle.go: -------------------------------------------------------------------------------- 1 | package approle 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/nais/msgraph.go/ptr" 6 | msgraph "github.com/nais/msgraph.go/v1.0" 7 | 8 | "github.com/nais/azureator/pkg/azure/permissions" 9 | ) 10 | 11 | func New(id msgraph.UUID, name string) msgraph.AppRole { 12 | return msgraph.AppRole{ 13 | AllowedMemberTypes: []string{"Application"}, 14 | Description: ptr.String(name), 15 | DisplayName: ptr.String(name), 16 | ID: &id, 17 | IsEnabled: ptr.Bool(true), 18 | Value: ptr.String(name), 19 | } 20 | } 21 | 22 | func NewGenerateId(name string) msgraph.AppRole { 23 | id := msgraph.UUID(uuid.New().String()) 24 | return New(id, name) 25 | } 26 | 27 | func DefaultRole() msgraph.AppRole { 28 | return New(msgraph.UUID(permissions.DefaultAppRoleId), permissions.DefaultAppRoleValue) 29 | } 30 | 31 | func DefaultGroupRole() msgraph.AppRole { 32 | return New(msgraph.UUID(permissions.DefaultGroupRoleId), permissions.DefaultGroupRoleValue) 33 | } 34 | 35 | func EnsureDefaultAppRoleIsEnabled(scopes []msgraph.AppRole) []msgraph.AppRole { 36 | for i := range scopes { 37 | if *scopes[i].Value == permissions.DefaultAppRoleValue && !*scopes[i].IsEnabled { 38 | scopes[i].IsEnabled = ptr.Bool(true) 39 | } 40 | } 41 | return scopes 42 | } 43 | 44 | func FromPermission(permission permissions.Permission) msgraph.AppRole { 45 | return New(permission.ID, permission.Name) 46 | } 47 | 48 | func RemoveDisabled(application msgraph.Application) []msgraph.AppRole { 49 | desired := make([]msgraph.AppRole, 0) 50 | 51 | for _, role := range application.AppRoles { 52 | if *role.IsEnabled { 53 | desired = append(desired, role) 54 | } 55 | } 56 | 57 | return desired 58 | } 59 | -------------------------------------------------------------------------------- /pkg/reconciler/interfaces.go: -------------------------------------------------------------------------------- 1 | package reconciler 2 | 3 | import ( 4 | "context" 5 | 6 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 7 | 8 | "github.com/nais/azureator/pkg/azure/credentials" 9 | "github.com/nais/azureator/pkg/azure/result" 10 | "github.com/nais/azureator/pkg/transaction" 11 | "github.com/nais/azureator/pkg/transaction/secrets" 12 | ) 13 | 14 | type AzureAdApplication interface { 15 | Azure() Azure 16 | Finalizer() Finalizer 17 | Secrets() Secrets 18 | 19 | ReportEvent(tx transaction.Transaction, eventType, event, message string) 20 | UpdateApplication(ctx context.Context, app *v1.AzureAdApplication, updateFunc func(existing *v1.AzureAdApplication) error) error 21 | } 22 | 23 | type Azure interface { 24 | Exists(tx transaction.Transaction) (bool, error) 25 | Delete(tx transaction.Transaction) error 26 | Process(tx transaction.Transaction) (*result.Application, error) 27 | ProcessOrphaned(tx transaction.Transaction) error 28 | 29 | AddCredentials(tx transaction.Transaction) (*credentials.Set, credentials.KeyID, error) 30 | DeleteExpiredCredentials(tx transaction.Transaction) error 31 | DeleteUnusedCredentials(tx transaction.Transaction) error 32 | RotateCredentials(tx transaction.Transaction) (*credentials.Set, credentials.KeyID, error) 33 | PurgeCredentials(tx transaction.Transaction) error 34 | ValidateCredentials(tx transaction.Transaction) (bool, error) 35 | } 36 | 37 | type Finalizer interface { 38 | Process(tx transaction.Transaction) (processed bool, err error) 39 | } 40 | 41 | type Secrets interface { 42 | Prepare(ctx context.Context, instance *v1.AzureAdApplication) (*secrets.Secrets, error) 43 | Process(tx transaction.Transaction, applicationResult *result.Application) error 44 | DeleteUnused(tx transaction.Transaction) error 45 | } 46 | -------------------------------------------------------------------------------- /pkg/azure/client/approleassignment/approleassignment.go: -------------------------------------------------------------------------------- 1 | package approleassignment 2 | 3 | import ( 4 | msgraph "github.com/nais/msgraph.go/v1.0" 5 | ) 6 | 7 | type appRoleAssignmentKey struct { 8 | AppRoleID msgraph.UUID 9 | PrincipalID msgraph.UUID 10 | ResourceID msgraph.UUID 11 | } 12 | 13 | func toAppRoleAssignmentKey(assignment msgraph.AppRoleAssignment) appRoleAssignmentKey { 14 | return appRoleAssignmentKey{ 15 | AppRoleID: *assignment.AppRoleID, 16 | PrincipalID: *assignment.PrincipalID, 17 | ResourceID: *assignment.ResourceID, 18 | } 19 | } 20 | 21 | // Difference returns the elements in `a` that aren't in `b`. 22 | // Shamelessly stolen and modified from https://stackoverflow.com/a/45428032/11868133 23 | func Difference(a, b List) List { 24 | mb := make(map[appRoleAssignmentKey]struct{}, len(b)) 25 | for _, x := range b { 26 | key := toAppRoleAssignmentKey(x) 27 | mb[key] = struct{}{} 28 | } 29 | diff := make(List, 0) 30 | for _, x := range a { 31 | key := toAppRoleAssignmentKey(x) 32 | if _, found := mb[key]; !found { 33 | diff = append(diff, x) 34 | } 35 | } 36 | return diff 37 | } 38 | 39 | // ToAssign returns a List describing the desired assignments that do not already exist, i.e. (desired - existing). 40 | func ToAssign(existing, desired List) List { 41 | return Difference(desired, existing) 42 | } 43 | 44 | // ToRevoke returns a List describing existing assignments that are no longer desired, i.e. (existing - desired). 45 | func ToRevoke(existing, desired List) List { 46 | return Difference(existing, desired) 47 | } 48 | 49 | // Unmodified returns a List describing desired assignments that are not modified, i.e. (existing - (toAssign + toRevoke)). 50 | func Unmodified(existing, toAssign, toRevoke List) List { 51 | return Difference(existing, append(toAssign, toRevoke...)) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/util/crypto/jwk.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/sha1" 5 | "crypto/sha256" 6 | "crypto/x509" 7 | "encoding/base64" 8 | 9 | "github.com/go-jose/go-jose/v4" 10 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 11 | ) 12 | 13 | const ( 14 | KeyUseSignature string = "sig" 15 | KeyAlgorithm string = "RS256" 16 | ) 17 | 18 | type Jwk struct { 19 | Private jose.JSONWebKey `json:"private"` 20 | PublicPem []byte `json:"publicPem"` 21 | } 22 | 23 | func GenerateJwk(application *v1.AzureAdApplication, clusterName string) (Jwk, error) { 24 | keyPair, err := NewRSAKeyPair() 25 | if err != nil { 26 | return Jwk{}, err 27 | } 28 | 29 | template := CertificateTemplate(application, clusterName) 30 | cert, err := GenerateCertificate(template, keyPair) 31 | if err != nil { 32 | return Jwk{}, err 33 | } 34 | certificates := []*x509.Certificate{cert} 35 | x5tSHA1 := sha1.Sum(certificates[0].Raw) 36 | x5tSHA256 := sha256.Sum256(certificates[0].Raw) 37 | keyId := base64.RawURLEncoding.EncodeToString(x5tSHA1[:]) 38 | 39 | jwk := jose.JSONWebKey{ 40 | Key: keyPair.Private, 41 | KeyID: keyId, 42 | Use: KeyUseSignature, 43 | Algorithm: KeyAlgorithm, 44 | Certificates: certificates, 45 | CertificateThumbprintSHA1: x5tSHA1[:], 46 | CertificateThumbprintSHA256: x5tSHA256[:], 47 | } 48 | 49 | return FromJwk(jwk), nil 50 | } 51 | 52 | func FromJwk(jwk jose.JSONWebKey) Jwk { 53 | jwkPublic := jwk.Public() 54 | 55 | return Jwk{ 56 | Private: jwk, 57 | PublicPem: ConvertToPem(jwkPublic.Certificates[0]), 58 | } 59 | } 60 | 61 | func (j Jwk) ToPrivateJwks() jose.JSONWebKeySet { 62 | return jose.JSONWebKeySet{ 63 | Keys: []jose.JSONWebKey{ 64 | j.Private, 65 | }, 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /pkg/azure/client/application/owners/owners.go: -------------------------------------------------------------------------------- 1 | package owners 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/nais/azureator/pkg/azure" 7 | "github.com/nais/azureator/pkg/azure/client/directoryobject" 8 | "github.com/nais/azureator/pkg/transaction" 9 | msgraph "github.com/nais/msgraph.go/v1.0" 10 | ) 11 | 12 | type Owners interface { 13 | Process(tx transaction.Transaction, owner azure.ServicePrincipalId) error 14 | } 15 | 16 | type owners struct { 17 | azure.RuntimeClient 18 | } 19 | 20 | func NewOwners(client azure.RuntimeClient) Owners { 21 | return owners{RuntimeClient: client} 22 | } 23 | 24 | func (o owners) Process(tx transaction.Transaction, owner azure.ServicePrincipalId) error { 25 | existing, err := o.get(tx) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | if directoryobject.ContainsOwner(existing, owner) { 31 | return nil 32 | } 33 | 34 | return o.add(tx, owner) 35 | } 36 | 37 | func (o owners) get(tx transaction.Transaction) ([]msgraph.DirectoryObject, error) { 38 | objectId := tx.Instance.GetObjectId() 39 | 40 | owners, err := o.GraphClient().Applications().ID(objectId).Owners().Request().GetN(tx.Ctx, o.MaxNumberOfPagesToFetch()) 41 | if err != nil { 42 | return owners, fmt.Errorf("listing owners for application: %w", err) 43 | } 44 | return owners, nil 45 | } 46 | 47 | func (o owners) add(tx transaction.Transaction, owner azure.ServicePrincipalId) error { 48 | objectId := tx.Instance.GetObjectId() 49 | 50 | body := directoryobject.ToOwnerPayload(owner) 51 | req := o.GraphClient().Applications().ID(objectId).Owners().Request() 52 | 53 | err := req.JSONRequest(tx.Ctx, "POST", "/$ref", body, nil) 54 | if err != nil { 55 | return fmt.Errorf("adding owner %q to application: %w", owner, err) 56 | } 57 | 58 | tx.Logger.Infof("assigned owner %q to application", owner) 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/azure/util/strings_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/nais/azureator/pkg/azure" 11 | ) 12 | 13 | func TestDisplayName(t *testing.T) { 14 | t.Run("DisplayName should return string with formatted timestamp", func(t *testing.T) { 15 | ti := time.Date(2000, 1, 1, 8, 0, 0, 0, time.UTC) 16 | actual := DisplayName(ti) 17 | assert.Equal(t, "azurerator-2000-01-01T08:00:00Z", actual) 18 | }) 19 | } 20 | 21 | func TestFilters(t *testing.T) { 22 | p := "test" 23 | cases := []struct { 24 | name string 25 | fn func(string) string 26 | expected string 27 | }{ 28 | { 29 | name: "Filter by AppId", 30 | fn: FilterByAppId, 31 | expected: fmt.Sprintf("appId eq '%s'", p), 32 | }, 33 | { 34 | name: "Filter by Client ID", 35 | fn: FilterByClientId, 36 | expected: fmt.Sprintf("clientId eq '%s'", p), 37 | }, 38 | { 39 | name: "Filter by DisplayName", 40 | fn: FilterByName, 41 | expected: fmt.Sprintf("displayName eq '%s'", p), 42 | }, 43 | } 44 | for _, c := range cases { 45 | t.Run(c.name, func(t *testing.T) { 46 | actual := c.fn(p) 47 | assert.Equal(t, c.expected, actual) 48 | }) 49 | } 50 | } 51 | 52 | func TestMapFiltersToFilter(t *testing.T) { 53 | t.Run("Empty slice of filters should return empty string", func(t *testing.T) { 54 | p := make([]azure.Filter, 0) 55 | actual := MapFiltersToFilter(p) 56 | assert.Empty(t, actual) 57 | }) 58 | 59 | t.Run("Multiple filters should return concatenated string of filters", func(t *testing.T) { 60 | name := FilterByName("some-name") 61 | appid := FilterByAppId("some-appid") 62 | 63 | p := []azure.Filter{name, appid} 64 | actual := MapFiltersToFilter(p) 65 | assert.Equal(t, fmt.Sprintf("%s %s", name, appid), actual) 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /pkg/azure/client/serviceprincipal/owners.go: -------------------------------------------------------------------------------- 1 | package serviceprincipal 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/nais/azureator/pkg/azure" 7 | "github.com/nais/azureator/pkg/azure/client/directoryobject" 8 | "github.com/nais/azureator/pkg/transaction" 9 | msgraph "github.com/nais/msgraph.go/v1.0" 10 | ) 11 | 12 | type Owners interface { 13 | Process(tx transaction.Transaction, owner azure.ServicePrincipalId) error 14 | } 15 | 16 | type owners struct { 17 | azure.RuntimeClient 18 | } 19 | 20 | func newOwners(client azure.RuntimeClient) Owners { 21 | return &owners{RuntimeClient: client} 22 | } 23 | 24 | func (o owners) Process(tx transaction.Transaction, owner azure.ServicePrincipalId) error { 25 | existing, err := o.get(tx) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | if directoryobject.ContainsOwner(existing, owner) { 31 | return nil 32 | } 33 | 34 | return o.add(tx, owner) 35 | } 36 | 37 | func (o owners) get(tx transaction.Transaction) ([]msgraph.DirectoryObject, error) { 38 | servicePrincipalId := tx.Instance.GetServicePrincipalId() 39 | owners, err := o.GraphClient().ServicePrincipals().ID(servicePrincipalId).Owners().Request().GetN(tx.Ctx, o.MaxNumberOfPagesToFetch()) 40 | if err != nil { 41 | return owners, fmt.Errorf("listing owners for service principal: %w", err) 42 | } 43 | return owners, nil 44 | } 45 | 46 | func (o owners) add(tx transaction.Transaction, owner azure.ServicePrincipalId) error { 47 | servicePrincipalId := tx.Instance.GetServicePrincipalId() 48 | 49 | body := directoryobject.ToOwnerPayload(owner) 50 | req := o.GraphClient().ServicePrincipals().ID(servicePrincipalId).Owners().Request() 51 | 52 | err := req.JSONRequest(tx.Ctx, "POST", "/$ref", body, nil) 53 | if err != nil { 54 | return fmt.Errorf("adding owner %q to service principal: %w", owner, err) 55 | } 56 | 57 | tx.Logger.Infof("assigned owner %q to service principal", owner) 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /pkg/config/azureopenidconfig.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | const ( 12 | WellKnownUrlTemplate = "https://login.microsoftonline.com/%s/v2.0/.well-known/openid-configuration" 13 | ) 14 | 15 | type AzureOpenIdConfig struct { 16 | Issuer string `json:"issuer"` 17 | TokenEndpoint string `json:"token_endpoint"` 18 | JwksURI string `json:"jwks_uri"` 19 | WellKnownEndpoint string `json:"well_known_endpoint,omitempty"` 20 | } 21 | 22 | func NewAzureOpenIdConfig(ctx context.Context, tenant AzureTenant) (*AzureOpenIdConfig, error) { 23 | wellKnownUrl := wellKnownUrl(tenant.Id) 24 | body, err := requestOpenIdConfiguration(ctx, wellKnownUrl) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | azureOpenIdConfig := &AzureOpenIdConfig{} 30 | if err := json.Unmarshal(body, &azureOpenIdConfig); err != nil { 31 | return nil, fmt.Errorf("unmarshalling: %w", err) 32 | } 33 | 34 | azureOpenIdConfig.WellKnownEndpoint = wellKnownUrl 35 | 36 | return azureOpenIdConfig, nil 37 | } 38 | 39 | func requestOpenIdConfiguration(ctx context.Context, url string) (body []byte, err error) { 40 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 41 | if err != nil { 42 | return nil, fmt.Errorf("creating client GET request: %w", err) 43 | } 44 | 45 | resp, err := http.DefaultClient.Do(req) 46 | if err != nil { 47 | return nil, fmt.Errorf("performing GET request to %s: %w", url, err) 48 | } 49 | defer resp.Body.Close() 50 | 51 | body, err = io.ReadAll(resp.Body) 52 | if err != nil { 53 | return nil, fmt.Errorf("reading server response: %w", err) 54 | } 55 | 56 | if resp.StatusCode >= 400 { 57 | return nil, fmt.Errorf("server responded with %s: %s", resp.Status, body) 58 | } 59 | 60 | return 61 | } 62 | 63 | func wellKnownUrl(tenant string) string { 64 | return fmt.Sprintf(WellKnownUrlTemplate, tenant) 65 | } 66 | -------------------------------------------------------------------------------- /pkg/azure/client/application/permissionscope/oauth2permissionscope.go: -------------------------------------------------------------------------------- 1 | package permissionscope 2 | 3 | import ( 4 | msgraph "github.com/nais/msgraph.go/v1.0" 5 | 6 | "github.com/nais/azureator/pkg/azure/permissions" 7 | ) 8 | 9 | type OAuth2PermissionScope interface { 10 | DescribeCreate(desired permissions.Permissions) Result 11 | DescribeUpdate(desired permissions.Permissions, existing []msgraph.PermissionScope) Result 12 | } 13 | 14 | type oAuth2PermissionScopes struct{} 15 | 16 | func NewOAuth2PermissionScopes() OAuth2PermissionScope { 17 | return oAuth2PermissionScopes{} 18 | } 19 | 20 | // DescribeCreate returns a slice describing the desired msgraph.PermissionScope to be created without actually creating them. 21 | func (o oAuth2PermissionScopes) DescribeCreate(desired permissions.Permissions) Result { 22 | existingSet := make(Map) 23 | return NewCreateResult(existingSet.ToCreate(desired)) 24 | } 25 | 26 | // DescribeUpdate returns a slice describing the desired state of both new (if any) and existing msgraph.PermissionScope, i.e: 27 | // 1) add any non-existing, desired scopes. 28 | // 2) disable existing, non-desired scopes. 29 | // It does not perform any modifying operations on the remote state in Azure AD. 30 | func (o oAuth2PermissionScopes) DescribeUpdate(desired permissions.Permissions, existing []msgraph.PermissionScope) Result { 31 | result := make([]msgraph.PermissionScope, 0) 32 | 33 | existingSet := ToMap(existing) 34 | 35 | toCreate := existingSet.ToCreate(desired) 36 | toDisable := existingSet.ToDisable(desired) 37 | unmodified := existingSet.Unmodified(toCreate, toDisable) 38 | 39 | result = append(result, unmodified.ToSlice()...) 40 | result = append(result, toCreate.ToSlice()...) 41 | result = append(result, toDisable.ToSlice()...) 42 | result = EnsureScopesRequireAdminConsent(result) 43 | result = EnsureDefaultScopeIsEnabled(result) 44 | return NewUpdateResult(toCreate, toDisable, unmodified, result) 45 | } 46 | -------------------------------------------------------------------------------- /charts/values.yaml: -------------------------------------------------------------------------------- 1 | nameOverride: "" 2 | fullnameOverride: "" 3 | 4 | # globals (if set) override any local values 5 | global: 6 | alerts: 7 | enabled: 8 | failedProcessingThreshold: 9 | clusterName: 10 | controller: 11 | leaderElection: 12 | maxConcurrentReconciles: 13 | secretRotation: 14 | secretRotationMaxAge: 15 | features: 16 | appRoleAssignmentRequired: 17 | claimsMappingPolicies: 18 | enabled: 19 | cleanupOrphans: 20 | customSecurityAttributes: 21 | enabled: 22 | groupsAssignment: 23 | enabled: 24 | google: 25 | federatedAuth: 26 | projectID: 27 | image: 28 | repository: 29 | tag: 30 | kafka: 31 | application: 32 | pool: 33 | tls: 34 | topic: 35 | networkPolicy: 36 | enabled: 37 | apiServerCIDR: 38 | webproxy: 39 | 40 | alerts: 41 | enabled: true 42 | failedProcessingThreshold: 50 43 | azure: 44 | clientID: # required 45 | clientSecret: # required if google.federatedAuth is disabled 46 | permissionGrantResourceID: # required 47 | tenant: 48 | name: # required 49 | id: # required 50 | clusterName: # required 51 | controller: 52 | leaderElection: true 53 | maxConcurrentReconciles: 10 54 | secretRotation: true 55 | secretRotationMaxAge: 168h # 7 days 56 | tenantNameStrictMatching: false 57 | features: 58 | appRoleAssignmentRequired: true 59 | claimsMappingPolicies: 60 | enabled: false 61 | id: 62 | cleanupOrphans: false 63 | customSecurityAttributes: 64 | enabled: false 65 | groupsAssignment: 66 | enabled: true 67 | allUsersGroupIDs: [] 68 | google: 69 | federatedAuth: false 70 | projectID: # required if google.federatedAuth is enabled 71 | image: 72 | repository: europe-north1-docker.pkg.dev/nais-io/nais/images/azurerator 73 | tag: latest 74 | kafka: 75 | application: false 76 | pool: nav-infrastructure 77 | tls: true 78 | topic: false 79 | networkPolicy: 80 | enabled: false 81 | apiServerCIDR: 82 | webproxy: false 83 | -------------------------------------------------------------------------------- /pkg/util/crypto/certificate.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/x509" 6 | "crypto/x509/pkix" 7 | "encoding/pem" 8 | "fmt" 9 | "math/big" 10 | "time" 11 | 12 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 13 | ) 14 | 15 | func GenerateCertificate(template *x509.Certificate, keyPair KeyPair) (*x509.Certificate, error) { 16 | derBytes, err := x509.CreateCertificate(rand.Reader, template, template, keyPair.Public, keyPair.Private) 17 | if err != nil { 18 | return nil, fmt.Errorf("failed to generate the certificate for key: %w", err) 19 | } 20 | 21 | cert, err := x509.ParseCertificate(derBytes) 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to parse certificate from DER data: %w", err) 24 | } 25 | return cert, nil 26 | } 27 | 28 | func CertificateTemplate(application *v1.AzureAdApplication, clusterName string) *x509.Certificate { 29 | notBefore := time.Now() 30 | 31 | var notAfter time.Time 32 | if application.Spec.SecretProtected { 33 | notAfter = notBefore.AddDate(99, 0, 0) 34 | } else { 35 | notAfter = notBefore.AddDate(1, 0, 0) 36 | } 37 | 38 | return &x509.Certificate{ 39 | SerialNumber: big.NewInt(1), 40 | Subject: pkix.Name{ 41 | Country: []string{"NO"}, 42 | Province: []string{"Oslo"}, 43 | Locality: []string{"Oslo"}, 44 | Organization: []string{"NAV (Arbeids- og velferdsdirektoratet"}, 45 | OrganizationalUnit: []string{"NAV IT"}, 46 | CommonName: fmt.Sprintf("%s.%s.%s.azurerator.nais.io", application.Name, application.Namespace, clusterName), 47 | }, 48 | NotBefore: notBefore, 49 | NotAfter: notAfter, 50 | KeyUsage: x509.KeyUsageDigitalSignature, 51 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, 52 | BasicConstraintsValid: true, 53 | } 54 | } 55 | 56 | func ConvertToPem(cert *x509.Certificate) []byte { 57 | return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/transaction/options/process.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/nais/azureator/pkg/customresources" 7 | ) 8 | 9 | func (b optionsBuilder) Process() (ProcessOptions, error) { 10 | instance := &b.instance 11 | 12 | hashChanged, err := customresources.IsHashChanged(instance) 13 | if err != nil { 14 | return ProcessOptions{}, err 15 | } 16 | 17 | secretNameChanged := customresources.SecretNameChanged(instance) 18 | hasResynchronizeAnnotation := customresources.HasResynchronizeAnnotation(instance) 19 | hasRotateAnnotation := customresources.HasRotateAnnotation(instance) 20 | hasExpiredSecrets := customresources.HasExpiredSecrets(instance, b.config.SecretRotation.MaxAge) 21 | tenantUnchanged := strings.Contains(instance.Status.SynchronizationTenant, b.config.Azure.Tenant.Id) 22 | 23 | needsSynchronization := hashChanged || secretNameChanged || hasExpiredSecrets || hasResynchronizeAnnotation || hasRotateAnnotation 24 | needsAzureSynchronization := hashChanged || hasResynchronizeAnnotation 25 | hasValidSecrets := !hasExpiredSecrets && tenantUnchanged && b.secrets.LatestCredentials.Valid && b.secrets.LatestCredentials.Set != nil 26 | needsSecretRotation := secretNameChanged || hasRotateAnnotation 27 | needsCleanup := !needsSecretRotation && !instance.Spec.SecretProtected && b.config.SecretRotation.Cleanup 28 | 29 | return ProcessOptions{ 30 | Synchronize: needsSynchronization, 31 | Azure: AzureOptions{ 32 | Synchronize: needsAzureSynchronization, 33 | CleanupOrphans: b.config.Azure.Features.CleanupOrphans.Enabled, 34 | }, 35 | Secret: SecretOptions{ 36 | Rotate: needsSecretRotation, 37 | Valid: hasValidSecrets, 38 | Cleanup: needsCleanup, 39 | }, 40 | }, nil 41 | } 42 | 43 | type ProcessOptions struct { 44 | Synchronize bool 45 | Azure AzureOptions 46 | Secret SecretOptions 47 | } 48 | 49 | type AzureOptions struct { 50 | Synchronize bool 51 | CleanupOrphans bool 52 | } 53 | 54 | type SecretOptions struct { 55 | Rotate bool 56 | Valid bool 57 | Cleanup bool 58 | } 59 | -------------------------------------------------------------------------------- /charts/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "azurerator.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "azurerator.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "azurerator.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "azurerator.labels" -}} 37 | app: {{ include "azurerator.name" . }} 38 | helm.sh/chart: {{ include "azurerator.chart" . }} 39 | {{ include "azurerator.selectorLabels" . }} 40 | {{- if .Chart.AppVersion }} 41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 42 | {{- end }} 43 | app.kubernetes.io/managed-by: {{ .Release.Service }} 44 | {{- end }} 45 | 46 | {{/* 47 | Selector labels 48 | */}} 49 | {{- define "azurerator.selectorLabels" -}} 50 | app.kubernetes.io/name: {{ include "azurerator.name" . }} 51 | app.kubernetes.io/instance: {{ .Release.Name }} 52 | {{- end }} 53 | 54 | {{/* 55 | Create the name of the service account to use 56 | */}} 57 | {{- define "azurerator.serviceAccountName" -}} 58 | {{- if .Values.serviceAccount.create }} 59 | {{- default (include "azurerator.fullname" .) .Values.serviceAccount.name }} 60 | {{- else }} 61 | {{- default "default" .Values.serviceAccount.name }} 62 | {{- end }} 63 | {{- end }} 64 | -------------------------------------------------------------------------------- /pkg/annotations/annotations.go: -------------------------------------------------------------------------------- 1 | package annotations 2 | 3 | import ( 4 | "strings" 5 | 6 | "sigs.k8s.io/controller-runtime/pkg/client" 7 | ) 8 | 9 | const ( 10 | PreserveKey = "azure.nais.io/preserve" 11 | ResynchronizeKey = "azure.nais.io/resync" 12 | RotateKey = "azure.nais.io/rotate" 13 | StakaterReloaderKey = "reloader.stakater.com/match" 14 | ) 15 | 16 | func SetAnnotation(resource client.Object, key, value string) { 17 | a := resource.GetAnnotations() 18 | if a == nil { 19 | a = make(map[string]string) 20 | } 21 | a[key] = value 22 | resource.SetAnnotations(a) 23 | } 24 | 25 | // AddToAnnotation appends the value to the existing list of values for the given key, separated by commas. 26 | // If there are no existing values, value itself is used. 27 | func AddToAnnotation(resource client.Object, key, value string) { 28 | a := resource.GetAnnotations() 29 | if a == nil { 30 | SetAnnotation(resource, key, value) 31 | return 32 | } 33 | 34 | existingValue, ok := a[key] 35 | if ok { 36 | a[key] = strings.Join([]string{existingValue, value}, ",") 37 | } else { 38 | a[key] = value 39 | } 40 | 41 | resource.SetAnnotations(a) 42 | } 43 | 44 | func HasAnnotation(resource client.Object, key string) (string, bool) { 45 | value, found := resource.GetAnnotations()[key] 46 | return value, found 47 | } 48 | 49 | func RemoveAnnotation(resource client.Object, key string) { 50 | _, found := HasAnnotation(resource, key) 51 | if found { 52 | a := resource.GetAnnotations() 53 | delete(a, key) 54 | resource.SetAnnotations(a) 55 | } 56 | } 57 | 58 | // RemoveFromAnnotation removes the first element from the annotation value, if there are multiple comma-separated values within the value. 59 | func RemoveFromAnnotation(resource client.Object, key string) { 60 | existingValue, found := HasAnnotation(resource, key) 61 | if !found { 62 | return 63 | } 64 | 65 | a := resource.GetAnnotations() 66 | 67 | existingValues := strings.Split(existingValue, ",") 68 | if len(existingValues) > 1 { 69 | a[key] = strings.Join(existingValues[1:], ",") 70 | } else { 71 | delete(a, key) 72 | } 73 | 74 | resource.SetAnnotations(a) 75 | } 76 | -------------------------------------------------------------------------------- /pkg/azure/client/application/permissionscope/permissionscope.go: -------------------------------------------------------------------------------- 1 | package permissionscope 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/nais/msgraph.go/ptr" 6 | msgraph "github.com/nais/msgraph.go/v1.0" 7 | 8 | "github.com/nais/azureator/pkg/azure/permissions" 9 | ) 10 | 11 | func EnsureScopesRequireAdminConsent(scopes []msgraph.PermissionScope) []msgraph.PermissionScope { 12 | for i := range scopes { 13 | if *scopes[i].Type != permissions.DefaultScopeType { 14 | scopes[i].Type = ptr.String(permissions.DefaultScopeType) 15 | } 16 | } 17 | return scopes 18 | } 19 | 20 | func EnsureDefaultScopeIsEnabled(scopes []msgraph.PermissionScope) []msgraph.PermissionScope { 21 | for i := range scopes { 22 | if *scopes[i].Value == permissions.DefaultPermissionScopeValue && !*scopes[i].IsEnabled { 23 | scopes[i].IsEnabled = ptr.Bool(true) 24 | } 25 | } 26 | return scopes 27 | } 28 | 29 | func NewGenerateId(name string) msgraph.PermissionScope { 30 | id := msgraph.UUID(uuid.New().String()) 31 | return New(id, name) 32 | } 33 | 34 | func New(id msgraph.UUID, name string) msgraph.PermissionScope { 35 | return msgraph.PermissionScope{ 36 | AdminConsentDescription: ptr.String(name), 37 | AdminConsentDisplayName: ptr.String(name), 38 | ID: &id, 39 | IsEnabled: ptr.Bool(true), 40 | Type: ptr.String(permissions.DefaultScopeType), 41 | Value: ptr.String(name), 42 | } 43 | } 44 | 45 | func DefaultScope() msgraph.PermissionScope { 46 | id := msgraph.UUID(permissions.DefaultPermissionScopeId) 47 | return New(id, permissions.DefaultPermissionScopeValue) 48 | } 49 | 50 | func FromPermission(permission permissions.Permission) msgraph.PermissionScope { 51 | return New(permission.ID, permission.Name) 52 | } 53 | 54 | func RemoveDisabled(application msgraph.Application) []msgraph.PermissionScope { 55 | desired := make([]msgraph.PermissionScope, 0) 56 | 57 | if application.API == nil { 58 | return desired 59 | } 60 | 61 | for _, scope := range application.API.OAuth2PermissionScopes { 62 | if *scope.IsEnabled { 63 | desired = append(desired, scope) 64 | } 65 | } 66 | 67 | return desired 68 | } 69 | -------------------------------------------------------------------------------- /pkg/azure/client/application/groupmembershipclaim/groupmembershipclaim.go: -------------------------------------------------------------------------------- 1 | package groupmembershipclaim 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | naisiov1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 8 | ) 9 | 10 | // GroupMembershipClaim is the type of groups to emit for tokens returned to the Application from Azure AD 11 | // See https://learn.microsoft.com/en-us/entra/identity-platform/reference-app-manifest#groupmembershipclaims-attribute. 12 | type GroupMembershipClaim = string 13 | 14 | const ( 15 | // All emits all the security groups, distribution groups, and Microsoft Entra directory roles that the signed-in user is a member of 16 | All GroupMembershipClaim = "All" 17 | // DirectoryRole emits the Microsoft Entra directory roles the user is a member of) 18 | DirectoryRole GroupMembershipClaim = "DirectoryRole" 19 | // SecurityGroup emits _all_ security groups the user is a member of in the groups claim. 20 | SecurityGroup GroupMembershipClaim = "SecurityGroup" 21 | // ApplicationGroup emits only the groups that are explicitly assigned to the application and the user is a member of. 22 | ApplicationGroup GroupMembershipClaim = "ApplicationGroup" 23 | // None results in no groups emitted. 24 | None GroupMembershipClaim = "None" 25 | ) 26 | 27 | func FromAzureAdApplication(app *naisiov1.AzureAdApplication) (GroupMembershipClaim, error) { 28 | if app.Spec.GroupMembershipClaims == nil { 29 | return "", nil 30 | } 31 | return Normalize(*app.Spec.GroupMembershipClaims) 32 | } 33 | 34 | func FromAzureAdApplicationOrDefault(app *naisiov1.AzureAdApplication, defaultValue GroupMembershipClaim) (GroupMembershipClaim, error) { 35 | claims := defaultValue 36 | if app.Spec.GroupMembershipClaims != nil { 37 | claims = *app.Spec.GroupMembershipClaims 38 | } 39 | return Normalize(claims) 40 | } 41 | 42 | func Normalize(claim string) (GroupMembershipClaim, error) { 43 | allowed := []GroupMembershipClaim{All, DirectoryRole, SecurityGroup, ApplicationGroup, None} 44 | for _, valid := range allowed { 45 | if strings.EqualFold(claim, valid) { 46 | return valid, nil 47 | } 48 | } 49 | return "", fmt.Errorf("invalid group membership claim: %q, must be one of %s", claim, allowed) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/azure/client/application/optionalclaims/optionalclaims.go: -------------------------------------------------------------------------------- 1 | package optionalclaims 2 | 3 | import ( 4 | "github.com/nais/msgraph.go/ptr" 5 | msgraph "github.com/nais/msgraph.go/v1.0" 6 | ) 7 | 8 | type OptionalClaims interface { 9 | DescribeCreate() *msgraph.OptionalClaims 10 | DescribeUpdate(existing msgraph.Application) *msgraph.OptionalClaims 11 | } 12 | 13 | type optionalClaims struct{} 14 | 15 | func NewOptionalClaims() OptionalClaims { 16 | return optionalClaims{} 17 | } 18 | 19 | func (o optionalClaims) DescribeCreate() *msgraph.OptionalClaims { 20 | return defaultClaims() 21 | } 22 | 23 | func (o optionalClaims) DescribeUpdate(existing msgraph.Application) *msgraph.OptionalClaims { 24 | existingClaims := existing.OptionalClaims 25 | if existingClaims == nil { 26 | return defaultClaims() 27 | } 28 | 29 | return mergeClaims(existingClaims, defaultClaims()) 30 | } 31 | 32 | func defaultClaims() *msgraph.OptionalClaims { 33 | return &msgraph.OptionalClaims{ 34 | AccessToken: []msgraph.OptionalClaim{ 35 | { 36 | Essential: ptr.Bool(true), 37 | Name: ptr.String("idtyp"), 38 | }, 39 | }, 40 | IDToken: []msgraph.OptionalClaim{ 41 | { 42 | Essential: ptr.Bool(true), 43 | Name: ptr.String("sid"), 44 | }, 45 | }, 46 | } 47 | } 48 | 49 | func mergeClaims(existing, override *msgraph.OptionalClaims) *msgraph.OptionalClaims { 50 | result := *existing 51 | 52 | merge := func(existing, override []msgraph.OptionalClaim) []msgraph.OptionalClaim { 53 | for _, overrideClaim := range override { 54 | seen := false 55 | 56 | for i, claim := range existing { 57 | if claim.Name == nil || overrideClaim.Name == nil { 58 | continue 59 | } 60 | 61 | if *claim.Name == *overrideClaim.Name { 62 | existing[i] = overrideClaim 63 | seen = true 64 | } 65 | } 66 | 67 | if !seen { 68 | existing = append(existing, overrideClaim) 69 | } 70 | } 71 | 72 | return existing 73 | } 74 | 75 | if len(override.IDToken) > 0 { 76 | result.IDToken = merge(result.IDToken, override.IDToken) 77 | } 78 | 79 | if len(override.AccessToken) > 0 { 80 | result.AccessToken = merge(result.AccessToken, override.AccessToken) 81 | } 82 | 83 | if len(override.Saml2Token) > 0 { 84 | result.Saml2Token = merge(result.Saml2Token, override.Saml2Token) 85 | } 86 | 87 | return &result 88 | } 89 | -------------------------------------------------------------------------------- /pkg/azure/client/application/identifieruri/identifieruri.go: -------------------------------------------------------------------------------- 1 | package identifieruri 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 8 | 9 | "github.com/nais/azureator/pkg/azure" 10 | "github.com/nais/azureator/pkg/azure/util" 11 | "github.com/nais/azureator/pkg/transaction" 12 | ) 13 | 14 | type IdentifierUri interface { 15 | Set(tx transaction.Transaction, uris azure.IdentifierUris) error 16 | } 17 | 18 | type identifierUri struct { 19 | Application 20 | } 21 | 22 | type Application interface { 23 | Patch(ctx context.Context, id azure.ObjectId, application any) error 24 | } 25 | 26 | func NewIdentifierUri(application Application) IdentifierUri { 27 | return identifierUri{Application: application} 28 | } 29 | 30 | func (i identifierUri) Set(tx transaction.Transaction, uris azure.IdentifierUris) error { 31 | objectId := tx.Instance.GetObjectId() 32 | app := util.EmptyApplication(). 33 | IdentifierUriList(uris). 34 | Build() 35 | if err := i.Application.Patch(tx.Ctx, objectId, app); err != nil { 36 | return fmt.Errorf("failed to add application identifier URI: %w", err) 37 | } 38 | 39 | return nil 40 | } 41 | 42 | func DescribeCreate(instance *v1.AzureAdApplication, clusterName string) azure.IdentifierUris { 43 | return defaultUris(instance, clusterName) 44 | } 45 | 46 | func DescribeUpdate(instance *v1.AzureAdApplication, existing azure.IdentifierUris, clusterName string) azure.IdentifierUris { 47 | result := make(azure.IdentifierUris, len(existing)) 48 | copy(result, existing) 49 | 50 | for _, uri := range defaultUris(instance, clusterName) { 51 | seen := false 52 | 53 | for _, existingUri := range existing { 54 | if uri == existingUri { 55 | seen = true 56 | break 57 | } 58 | } 59 | 60 | if !seen { 61 | result = append(result, uri) 62 | } 63 | } 64 | 65 | return result 66 | } 67 | 68 | func uriClientId(id azure.ClientId) string { 69 | return fmt.Sprintf("api://%s", id) 70 | } 71 | 72 | func uriHumanReadable(spec *v1.AzureAdApplication, clusterName string) string { 73 | return fmt.Sprintf("api://%s.%s.%s", clusterName, spec.GetNamespace(), spec.GetName()) 74 | } 75 | 76 | func defaultUris(instance *v1.AzureAdApplication, clusterName string) azure.IdentifierUris { 77 | return []string{ 78 | uriClientId(instance.GetClientId()), 79 | uriHumanReadable(instance, clusterName), 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pkg/azure/client/approleassignment/list.go: -------------------------------------------------------------------------------- 1 | package approleassignment 2 | 3 | import ( 4 | msgraph "github.com/nais/msgraph.go/v1.0" 5 | 6 | "github.com/nais/azureator/pkg/azure/permissions" 7 | "github.com/nais/azureator/pkg/azure/resource" 8 | ) 9 | 10 | type List []msgraph.AppRoleAssignment 11 | 12 | func ToAppRoleAssignments(resources resource.Resources, target string, role permissions.Permission) List { 13 | result := make(List, 0) 14 | 15 | for _, re := range resources { 16 | result = append(result, re.ToAppRoleAssignment(target, role)) 17 | } 18 | 19 | return result 20 | } 21 | 22 | func (l List) Has(assignment msgraph.AppRoleAssignment) bool { 23 | for _, a := range l { 24 | equalPrincipalID := *a.PrincipalID == *assignment.PrincipalID 25 | equalAppRoleID := *a.AppRoleID == *assignment.AppRoleID 26 | equalPrincipalType := *a.PrincipalType == *assignment.PrincipalType 27 | 28 | if equalPrincipalID && equalAppRoleID && equalPrincipalType { 29 | return true 30 | } 31 | } 32 | return false 33 | } 34 | 35 | func (l List) HasResource(in resource.Resource) bool { 36 | for _, a := range l { 37 | equalPrincipalID := *a.PrincipalID == msgraph.UUID(in.ObjectId) 38 | equalPrincipalType := resource.PrincipalType(*a.PrincipalType) == in.PrincipalType 39 | 40 | if equalPrincipalID && equalPrincipalType { 41 | return true 42 | } 43 | } 44 | return false 45 | } 46 | 47 | func (l List) FilterByRoleID(roleId msgraph.UUID) List { 48 | filtered := make(List, 0) 49 | for _, assignment := range l { 50 | if *assignment.AppRoleID == roleId { 51 | filtered = append(filtered, assignment) 52 | } 53 | } 54 | return filtered 55 | } 56 | 57 | func (l List) FilterByType(principalType resource.PrincipalType) List { 58 | filtered := make(List, 0) 59 | for _, assignment := range l { 60 | if resource.PrincipalType(*assignment.PrincipalType) == principalType { 61 | filtered = append(filtered, assignment) 62 | } 63 | } 64 | return filtered 65 | } 66 | 67 | func (l List) Groups() List { 68 | return l.FilterByType(resource.PrincipalTypeGroup) 69 | } 70 | 71 | func (l List) ServicePrincipals() List { 72 | return l.FilterByType(resource.PrincipalTypeServicePrincipal) 73 | } 74 | 75 | func (l List) WithoutMatchingRole(roles permissions.Permissions) List { 76 | nonDesired := make(List, 0) 77 | 78 | for _, assignment := range l { 79 | if !roles.HasRoleID(*assignment.AppRoleID) { 80 | nonDesired = append(nonDesired, assignment) 81 | } 82 | } 83 | 84 | return nonDesired 85 | } 86 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20) 2 | ENVTEST_VERSION ?= release-0.19 3 | #ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) 4 | ENVTEST_K8S_VERSION ?= 1.31.0 5 | ## Location to install dependencies to 6 | LOCALBIN ?= $(shell pwd)/bin 7 | $(LOCALBIN): 8 | mkdir -p $(LOCALBIN) 9 | 10 | ENVTEST ?= $(LOCALBIN)/setup-envtest 11 | 12 | # Run tests excluding integration tests 13 | test: fmt vet 14 | go test ./... -coverprofile cover.out -short 15 | 16 | # Run against the configured Kubernetes cluster in ~/.kube/config 17 | run: fmt vet 18 | go run cmd/azurerator/main.go 19 | 20 | fmt: 21 | go tool gofumpt -w ./ 22 | 23 | vet: 24 | go vet ./... 25 | 26 | vuln: 27 | go tool govulncheck ./... 28 | 29 | static: 30 | go tool staticcheck ./... 31 | 32 | deadcode: 33 | go tool deadcode -test ./... 34 | 35 | helm-lint: 36 | helm lint --strict ./charts 37 | 38 | actions-lint: 39 | go tool ratchet lint .github/workflows/*.yaml 40 | 41 | check: static deadcode vuln actions-lint 42 | 43 | install: 44 | kubectl apply -f https://raw.githubusercontent.com/nais/liberator/main/config/crd/bases/nais.io_azureadapplications.yaml 45 | kubectl apply -f ./hack/resources/ 46 | 47 | sample: 48 | kubectl apply -f ./config/samples/azureadapplication.yaml 49 | 50 | ##@ Dependencies 51 | setup-envtest: envtest ## Download the binaries required for ENVTEST in the local bin directory. 52 | @echo "Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)..." 53 | @$(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path || { \ 54 | echo "Error: Failed to set up envtest binaries for version $(ENVTEST_K8S_VERSION)."; \ 55 | exit 1; \ 56 | } 57 | 58 | envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. 59 | $(ENVTEST): $(LOCALBIN) 60 | $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) 61 | 62 | # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist 63 | # $1 - target path with name of binary 64 | # $2 - package url which can be installed 65 | # $3 - specific version of package 66 | define go-install-tool 67 | @[ -f "$(1)-$(3)" ] || { \ 68 | set -e; \ 69 | package=$(2)@$(3) ;\ 70 | echo "Downloading $${package}" ;\ 71 | rm -f $(1) || true ;\ 72 | GOBIN=$(LOCALBIN) go install $${package} ;\ 73 | mv $(1) $(1)-$(3) ;\ 74 | } ;\ 75 | ln -sf $(1)-$(3) $(1) 76 | endef 77 | -------------------------------------------------------------------------------- /pkg/azure/client/oauth2permissiongrant/oauth2permissiongrant.go: -------------------------------------------------------------------------------- 1 | package oauth2permissiongrant 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/nais/msgraph.go/ptr" 7 | msgraph "github.com/nais/msgraph.go/v1.0" 8 | 9 | "github.com/nais/azureator/pkg/azure" 10 | "github.com/nais/azureator/pkg/azure/util" 11 | "github.com/nais/azureator/pkg/transaction" 12 | ) 13 | 14 | type OAuth2PermissionGrant interface { 15 | Process(tx transaction.Transaction) error 16 | } 17 | 18 | type oAuth2PermissionGrant struct { 19 | azure.RuntimeClient 20 | } 21 | 22 | func NewOAuth2PermissionGrant(runtimeClient azure.RuntimeClient) OAuth2PermissionGrant { 23 | return oAuth2PermissionGrant{RuntimeClient: runtimeClient} 24 | } 25 | 26 | func (o oAuth2PermissionGrant) Process(tx transaction.Transaction) error { 27 | exists, err := o.exists(tx) 28 | if err != nil { 29 | return err 30 | } 31 | if exists { 32 | return nil 33 | } 34 | 35 | servicePrincipalId := tx.Instance.GetServicePrincipalId() 36 | 37 | _, err = o.GraphClient().OAuth2PermissionGrants().Request().Add(tx.Ctx, o.toGrant(servicePrincipalId)) 38 | if err != nil { 39 | return fmt.Errorf("registering oauth2 permission grants: %w", err) 40 | } 41 | return nil 42 | } 43 | 44 | func (o oAuth2PermissionGrant) exists(tx transaction.Transaction) (bool, error) { 45 | // For some odd reason Graph has defined 'clientId' in the oAuth2PermissionGrant resource to be the _objectId_ 46 | // for the ServicePrincipal when referring to the id of the ServicePrincipal granted consent... 47 | clientId := tx.Instance.GetServicePrincipalId() 48 | r := o.GraphClient().OAuth2PermissionGrants().Request() 49 | r.Filter(util.FilterByClientId(clientId)) 50 | grants, err := r.GetN(tx.Ctx, o.MaxNumberOfPagesToFetch()) 51 | if err != nil { 52 | return false, fmt.Errorf("looking up oauth2 permission grants: %w", err) 53 | } 54 | return len(grants) > 0, nil 55 | } 56 | 57 | // OAuth2 permission grants allows us to pre-approve this application for the defined scopes/permissions set. 58 | // This results in the enduser not having to manually consent whenever interacting with the application, e.g. during 59 | // an OIDC login flow. 60 | func (o oAuth2PermissionGrant) toGrant(servicePrincipalId azure.ServicePrincipalId) *msgraph.OAuth2PermissionGrant { 61 | return &msgraph.OAuth2PermissionGrant{ 62 | ClientID: ptr.String(servicePrincipalId), 63 | ConsentType: ptr.String("AllPrincipals"), 64 | ResourceID: ptr.String(o.Config().PermissionGrantResourceId), 65 | Scope: ptr.String("openid User.Read GroupMember.Read.All"), 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /pkg/azure/client/auth.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" 8 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 9 | "github.com/nais/msgraph.go/msauth" 10 | "golang.org/x/oauth2" 11 | "google.golang.org/api/impersonate" 12 | 13 | "github.com/nais/azureator/pkg/config" 14 | ) 15 | 16 | var scopes = []string{msauth.DefaultMSGraphScope} 17 | 18 | func NewClientCredentialsTokenSource(ctx context.Context, cfg *config.AzureConfig) (oauth2.TokenSource, error) { 19 | m := msauth.NewManager() 20 | ts, err := m.ClientCredentialsGrant(ctx, cfg.Tenant.Id, cfg.Auth.ClientId, cfg.Auth.ClientSecret, scopes) 21 | if err != nil { 22 | return nil, fmt.Errorf("performing client credentials grant: %w", err) 23 | } 24 | 25 | return ts, nil 26 | } 27 | 28 | type GoogleFederatedCredentialTokenSource struct { 29 | cred *azidentity.ClientAssertionCredential 30 | ctx context.Context 31 | opts policy.TokenRequestOptions 32 | } 33 | 34 | func (in *GoogleFederatedCredentialTokenSource) Token() (*oauth2.Token, error) { 35 | tok, err := in.cred.GetToken(in.ctx, in.opts) 36 | if err != nil { 37 | return nil, fmt.Errorf("fetching azure token: %w", err) 38 | } 39 | 40 | return &oauth2.Token{ 41 | AccessToken: tok.Token, 42 | TokenType: "bearer", 43 | Expiry: tok.ExpiresOn, 44 | }, nil 45 | } 46 | 47 | func NewGoogleFederatedCredentialsTokenSource(ctx context.Context, cfg *config.AzureConfig) (oauth2.TokenSource, error) { 48 | googleTokenSource, err := impersonate.IDTokenSource(ctx, impersonate.IDTokenConfig{ 49 | Audience: "api://AzureADTokenExchange", 50 | TargetPrincipal: fmt.Sprintf("azurerator@%s.iam.gserviceaccount.com", cfg.Auth.Google.ProjectID), 51 | IncludeEmail: true, 52 | }) 53 | if err != nil { 54 | return nil, fmt.Errorf("creating google token source: %w", err) 55 | } 56 | 57 | googleAssertion := func(ctx context.Context) (string, error) { 58 | t, err := googleTokenSource.Token() 59 | if err != nil { 60 | return "", fmt.Errorf("fetching google token: %w", err) 61 | } 62 | 63 | return t.AccessToken, nil 64 | } 65 | 66 | cred, err := azidentity.NewClientAssertionCredential(cfg.Tenant.Id, cfg.Auth.ClientId, googleAssertion, nil) 67 | if err != nil { 68 | return nil, fmt.Errorf("creating azure assertion credential: %w", err) 69 | } 70 | 71 | ts := &GoogleFederatedCredentialTokenSource{ 72 | cred: cred, 73 | ctx: ctx, 74 | opts: policy.TokenRequestOptions{ 75 | Scopes: scopes, 76 | }, 77 | } 78 | 79 | return oauth2.ReuseTokenSource(nil, ts), nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/azure/client/application/redirecturi/redirecturi.go: -------------------------------------------------------------------------------- 1 | package redirecturi 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/asaskevich/govalidator" 7 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 8 | msgraph "github.com/nais/msgraph.go/v1.0" 9 | 10 | "github.com/nais/azureator/pkg/azure" 11 | "github.com/nais/azureator/pkg/transaction" 12 | stringutils "github.com/nais/azureator/pkg/util/strings" 13 | ) 14 | 15 | // Workaround to include empty array of RedirectUris in JSON serialization. 16 | // The autogenerated library code uses 'omitempty' for RedirectUris, which when empty 17 | // leaves the list of redirect URIs unchanged and non-empty and is thus considered unmodified in the PATCH operation. 18 | type emptiableRedirectUris struct { 19 | RedirectUris []string `json:"redirectUris"` 20 | } 21 | 22 | type RedirectUri interface { 23 | Update(tx transaction.Transaction) error 24 | } 25 | 26 | type redirectUri struct { 27 | Application 28 | } 29 | 30 | type Application interface { 31 | Patch(ctx context.Context, id azure.ObjectId, application any) error 32 | } 33 | 34 | func NewRedirectUri(application Application) RedirectUri { 35 | return redirectUri{Application: application} 36 | } 37 | 38 | func (r redirectUri) Update(tx transaction.Transaction) error { 39 | objectId := tx.Instance.GetObjectId() 40 | app := App(tx.Instance) 41 | 42 | return r.Application.Patch(tx.Ctx, objectId, app) 43 | } 44 | 45 | func App(instance *v1.AzureAdApplication) any { 46 | redirectUris := ReplyUrlsToStringSlice(instance) 47 | 48 | if instance.Spec.SinglePageApplication != nil && *instance.Spec.SinglePageApplication { 49 | return singlePageApp(redirectUris) 50 | } 51 | return webApp(redirectUris) 52 | } 53 | 54 | func ReplyUrlsToStringSlice(resource *v1.AzureAdApplication) []string { 55 | replyUrls := make([]string, 0) 56 | for _, v := range resource.Spec.ReplyUrls { 57 | url := string(v.Url) 58 | 59 | ok := govalidator.IsURL(url) 60 | if ok { 61 | replyUrls = append(replyUrls, url) 62 | } 63 | } 64 | return stringutils.RemoveDuplicates(replyUrls) 65 | } 66 | 67 | func webApp(redirectUris []string) any { 68 | return &struct { 69 | msgraph.DirectoryObject 70 | Web emptiableRedirectUris `json:"web"` 71 | Spa emptiableRedirectUris `json:"spa"` 72 | }{ 73 | Web: emptiableRedirectUris{ 74 | RedirectUris: redirectUris, 75 | }, 76 | Spa: emptiableRedirectUris{ 77 | RedirectUris: make([]string, 0), 78 | }, 79 | } 80 | } 81 | 82 | func singlePageApp(redirectUris []string) any { 83 | return &struct { 84 | msgraph.DirectoryObject 85 | Web emptiableRedirectUris `json:"web"` 86 | Spa emptiableRedirectUris `json:"spa"` 87 | }{ 88 | Web: emptiableRedirectUris{ 89 | RedirectUris: make([]string, 0), 90 | }, 91 | Spa: emptiableRedirectUris{ 92 | RedirectUris: redirectUris, 93 | }, 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /pkg/azure/client/application/approle/approlemap.go: -------------------------------------------------------------------------------- 1 | package approle 2 | 3 | import ( 4 | "github.com/nais/msgraph.go/ptr" 5 | msgraph "github.com/nais/msgraph.go/v1.0" 6 | 7 | "github.com/nais/azureator/pkg/azure/permissions" 8 | ) 9 | 10 | type Map map[string]msgraph.AppRole 11 | 12 | func ToMap(roles []msgraph.AppRole) Map { 13 | seen := make(Map) 14 | 15 | for _, role := range roles { 16 | seen.Add(role) 17 | } 18 | 19 | return seen 20 | } 21 | 22 | func (m Map) Add(role msgraph.AppRole) { 23 | name := *role.Value 24 | 25 | if _, found := m[name]; !found { 26 | m[name] = role 27 | } 28 | } 29 | 30 | func (m Map) ToSlice() []msgraph.AppRole { 31 | roles := make([]msgraph.AppRole, 0) 32 | 33 | for _, appRole := range m { 34 | roles = append(roles, appRole) 35 | } 36 | 37 | return roles 38 | } 39 | 40 | // ToCreate returns a Map describing the desired, non-existing roles to be created. 41 | func (m Map) ToCreate(desired permissions.Permissions) Map { 42 | toCreate := make(Map) 43 | 44 | // ensure default AppRole is created if it doesn't exist 45 | if _, found := m[permissions.DefaultAppRoleValue]; !found { 46 | toCreate[permissions.DefaultAppRoleValue] = DefaultRole() 47 | } 48 | 49 | for _, role := range desired { 50 | if role.Name == permissions.DefaultAppRoleValue { 51 | continue 52 | } 53 | 54 | if _, found := m[role.Name]; !found { 55 | toCreate[role.Name] = FromPermission(role) 56 | } 57 | } 58 | 59 | return toCreate 60 | } 61 | 62 | // ToDisable returns a Map describing the existing, non-desired scopes to be disabled. 63 | func (m Map) ToDisable(desired permissions.Permissions) Map { 64 | toDisable := make(Map) 65 | 66 | for _, role := range m { 67 | name := *role.Value 68 | if _, found := desired[name]; !found { 69 | disabledRole := role 70 | disabledRole.IsEnabled = ptr.Bool(false) 71 | toDisable[name] = disabledRole 72 | } 73 | } 74 | 75 | // ensure default AppRole is not disabled 76 | delete(toDisable, permissions.DefaultAppRoleValue) 77 | return toDisable 78 | } 79 | 80 | // Unmodified returns a Map describing existing scopes that should not be modified. 81 | // I.e. the difference of (existing - (toCreate + toDisable)) 82 | func (m Map) Unmodified(toCreate, toDisable Map) Map { 83 | unmodified := make(Map) 84 | 85 | for _, role := range m { 86 | name := *role.Value 87 | id := *role.ID 88 | 89 | _, foundToCreate := toCreate[name] 90 | _, foundToDisable := toDisable[name] 91 | 92 | if foundToCreate || foundToDisable { 93 | continue 94 | } 95 | 96 | unmodified[name] = New(id, name) 97 | } 98 | 99 | return unmodified 100 | } 101 | 102 | func (m Map) ToPermissionList() permissions.PermissionList { 103 | result := make(permissions.PermissionList, 0) 104 | 105 | for _, role := range m { 106 | result = append(result, permissions.FromAppRole(role)) 107 | } 108 | 109 | return result 110 | } 111 | -------------------------------------------------------------------------------- /charts/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: ClusterRole 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | metadata: 5 | labels: 6 | {{ include "azurerator.labels" . | nindent 4 }} 7 | name: {{ include "azurerator.fullname" . }} 8 | rules: 9 | - apiGroups: 10 | - nais.io 11 | resources: 12 | - azureadapplications 13 | - azureadapplications/status 14 | verbs: 15 | - get 16 | - list 17 | - watch 18 | - create 19 | - update 20 | - apiGroups: 21 | - "" 22 | resources: 23 | - secrets 24 | - events 25 | verbs: 26 | - get 27 | - list 28 | - watch 29 | - create 30 | - delete 31 | - update 32 | - patch 33 | - apiGroups: 34 | - "" 35 | resources: 36 | - pods 37 | - namespaces 38 | verbs: 39 | - list 40 | - get 41 | - watch 42 | - apiGroups: 43 | - apps 44 | resources: 45 | - replicasets 46 | verbs: 47 | - list 48 | - get 49 | - watch 50 | 51 | --- 52 | # permissions to do leader election. 53 | apiVersion: rbac.authorization.k8s.io/v1 54 | kind: Role 55 | metadata: 56 | name: {{ include "azurerator.fullname" . }}-leader-election 57 | labels: 58 | {{ include "azurerator.labels" . | nindent 4 }} 59 | rules: 60 | - apiGroups: 61 | - "" 62 | resources: 63 | - configmaps 64 | verbs: 65 | - get 66 | - list 67 | - watch 68 | - create 69 | - update 70 | - patch 71 | - delete 72 | - apiGroups: 73 | - coordination.k8s.io 74 | resources: 75 | - leases 76 | verbs: 77 | - get 78 | - list 79 | - watch 80 | - create 81 | - update 82 | - patch 83 | - delete 84 | - apiGroups: 85 | - "" 86 | resources: 87 | - events 88 | verbs: 89 | - create 90 | - patch 91 | 92 | --- 93 | kind: ClusterRoleBinding 94 | apiVersion: rbac.authorization.k8s.io/v1 95 | metadata: 96 | name: {{ include "azurerator.fullname" . }} 97 | labels: 98 | {{ include "azurerator.labels" . | nindent 4 }} 99 | roleRef: 100 | apiGroup: rbac.authorization.k8s.io 101 | kind: ClusterRole 102 | name: {{ include "azurerator.fullname" . }} 103 | subjects: 104 | - kind: ServiceAccount 105 | name: {{ include "azurerator.fullname" . }} 106 | namespace: "{{ .Release.Namespace }}" 107 | --- 108 | apiVersion: rbac.authorization.k8s.io/v1 109 | kind: RoleBinding 110 | metadata: 111 | labels: 112 | {{ include "azurerator.labels" . | nindent 4 }} 113 | name: {{ include "azurerator.fullname" . }}-leader-election 114 | roleRef: 115 | apiGroup: rbac.authorization.k8s.io 116 | kind: Role 117 | name: {{ include "azurerator.fullname" . }}-leader-election 118 | subjects: 119 | - kind: ServiceAccount 120 | name: {{ include "azurerator.fullname" . }} 121 | namespace: "{{ .Release.Namespace }}" 122 | -------------------------------------------------------------------------------- /pkg/azure/util/application_builder.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | naisiov1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 5 | "github.com/nais/msgraph.go/ptr" 6 | msgraph "github.com/nais/msgraph.go/v1.0" 7 | 8 | "github.com/nais/azureator/pkg/azure" 9 | "github.com/nais/azureator/pkg/azure/client/application/groupmembershipclaim" 10 | ) 11 | 12 | type ApplicationBuilder struct { 13 | *msgraph.Application 14 | } 15 | 16 | func EmptyApplication() ApplicationBuilder { 17 | return ApplicationBuilder{&msgraph.Application{}} 18 | } 19 | 20 | func Application(template *msgraph.Application) ApplicationBuilder { 21 | return ApplicationBuilder{template} 22 | } 23 | 24 | func (a ApplicationBuilder) Keys(keyCredentials []msgraph.KeyCredential) ApplicationBuilder { 25 | a.KeyCredentials = keyCredentials 26 | return a 27 | } 28 | 29 | func (a ApplicationBuilder) IdentifierUriList(uris azure.IdentifierUris) ApplicationBuilder { 30 | a.IdentifierUris = uris 31 | return a 32 | } 33 | 34 | func (a ApplicationBuilder) PreAuthorizedApps(preAuthApps []msgraph.PreAuthorizedApplication) ApplicationBuilder { 35 | a.API.PreAuthorizedApplications = preAuthApps 36 | return a 37 | } 38 | 39 | func (a ApplicationBuilder) ResourceAccess(access []msgraph.RequiredResourceAccess) ApplicationBuilder { 40 | a.RequiredResourceAccess = access 41 | return a 42 | } 43 | 44 | func (a ApplicationBuilder) GroupMembershipClaims(groupMembershipClaim groupmembershipclaim.GroupMembershipClaim) ApplicationBuilder { 45 | a.Application.GroupMembershipClaims = ptr.String(groupMembershipClaim) 46 | return a 47 | } 48 | 49 | func (a ApplicationBuilder) AppRoles(appRoles []msgraph.AppRole) ApplicationBuilder { 50 | a.Application.AppRoles = appRoles 51 | return a 52 | } 53 | 54 | func (a ApplicationBuilder) RedirectUris(redirectUris []string, instance *naisiov1.AzureAdApplication) ApplicationBuilder { 55 | if instance.Spec.SinglePageApplication != nil && *instance.Spec.SinglePageApplication { 56 | return a.singlePageAppRedirectUri(redirectUris) 57 | } 58 | return a.webAppRedirectUri(redirectUris) 59 | } 60 | 61 | func (a ApplicationBuilder) webAppRedirectUri(redirectUris []string) ApplicationBuilder { 62 | if a.Web == nil { 63 | a.Web = &msgraph.WebApplication{} 64 | } 65 | a.Web.RedirectUris = redirectUris 66 | return a 67 | } 68 | 69 | func (a ApplicationBuilder) singlePageAppRedirectUri(redirectUris []string) ApplicationBuilder { 70 | if a.Spa == nil { 71 | a.Spa = &msgraph.SpaApplication{} 72 | } 73 | a.Spa.RedirectUris = redirectUris 74 | return a 75 | } 76 | 77 | func (a ApplicationBuilder) PermissionScopes(scopes []msgraph.PermissionScope) ApplicationBuilder { 78 | if a.API == nil { 79 | a.API = &msgraph.APIApplication{} 80 | } 81 | a.API.OAuth2PermissionScopes = scopes 82 | return a 83 | } 84 | 85 | func (a ApplicationBuilder) OptionalClaims(optionalClaims *msgraph.OptionalClaims) ApplicationBuilder { 86 | a.Application.OptionalClaims = optionalClaims 87 | return a 88 | } 89 | 90 | func (a ApplicationBuilder) Build() *msgraph.Application { 91 | return a.Application 92 | } 93 | -------------------------------------------------------------------------------- /pkg/azure/client/application/identifieruri/identifieruri_test.go: -------------------------------------------------------------------------------- 1 | package identifieruri_test 2 | 3 | import ( 4 | "testing" 5 | 6 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/nais/azureator/pkg/azure" 10 | "github.com/nais/azureator/pkg/azure/client/application/identifieruri" 11 | ) 12 | 13 | func TestDescribeCreate(t *testing.T) { 14 | spec := spec() 15 | clusterName := "test-cluster" 16 | actual := identifieruri.DescribeCreate(spec, clusterName) 17 | expected := azure.IdentifierUris{ 18 | "api://test-cluster.test-namespace.test", 19 | "api://some-uuid", 20 | } 21 | 22 | assert.ElementsMatch(t, expected, actual) 23 | } 24 | 25 | func TestDescribeUpdate(t *testing.T) { 26 | clusterName := "test-cluster" 27 | 28 | for _, test := range []struct { 29 | name string 30 | existing azure.IdentifierUris 31 | expected azure.IdentifierUris 32 | }{ 33 | { 34 | name: "no existing uris", 35 | existing: nil, 36 | expected: azure.IdentifierUris{ 37 | "api://test-cluster.test-namespace.test", 38 | "api://some-uuid", 39 | }, 40 | }, 41 | { 42 | name: "existing uris, no overlap with default", 43 | existing: azure.IdentifierUris{ 44 | "api://some-other-uri", 45 | }, 46 | expected: azure.IdentifierUris{ 47 | "api://some-other-uri", 48 | "api://test-cluster.test-namespace.test", 49 | "api://some-uuid", 50 | }, 51 | }, 52 | { 53 | name: "existing uris, partial overlap with default", 54 | existing: azure.IdentifierUris{ 55 | "api://some-other-uri", 56 | "api://test-cluster.test-namespace.test", 57 | }, 58 | expected: azure.IdentifierUris{ 59 | "api://some-other-uri", 60 | "api://test-cluster.test-namespace.test", 61 | "api://some-uuid", 62 | }, 63 | }, 64 | { 65 | name: "existing uris, full overlap with default", 66 | existing: azure.IdentifierUris{ 67 | "api://some-other-uri", 68 | "api://test-cluster.test-namespace.test", 69 | "api://some-uuid", 70 | }, 71 | expected: azure.IdentifierUris{ 72 | "api://some-other-uri", 73 | "api://test-cluster.test-namespace.test", 74 | "api://some-uuid", 75 | }, 76 | }, 77 | { 78 | name: "existing uris, equal to default", 79 | existing: azure.IdentifierUris{ 80 | "api://test-cluster.test-namespace.test", 81 | "api://some-uuid", 82 | }, 83 | expected: azure.IdentifierUris{ 84 | "api://test-cluster.test-namespace.test", 85 | "api://some-uuid", 86 | }, 87 | }, 88 | } { 89 | t.Run(test.name, func(t *testing.T) { 90 | spec := spec() 91 | actual := identifieruri.DescribeUpdate(spec, test.existing, clusterName) 92 | assert.ElementsMatch(t, test.expected, actual) 93 | }) 94 | } 95 | } 96 | 97 | func spec() *v1.AzureAdApplication { 98 | spec := &v1.AzureAdApplication{} 99 | spec.SetName("test") 100 | spec.SetNamespace("test-namespace") 101 | spec.Status.ClientId = "some-uuid" 102 | return spec 103 | } 104 | -------------------------------------------------------------------------------- /pkg/azure/client/application/permissionscope/permissionscopemap.go: -------------------------------------------------------------------------------- 1 | package permissionscope 2 | 3 | import ( 4 | "github.com/nais/msgraph.go/ptr" 5 | msgraph "github.com/nais/msgraph.go/v1.0" 6 | 7 | "github.com/nais/azureator/pkg/azure/permissions" 8 | ) 9 | 10 | type Map map[string]msgraph.PermissionScope 11 | 12 | func ToMap(scopes []msgraph.PermissionScope) Map { 13 | seen := make(Map) 14 | 15 | for _, scope := range scopes { 16 | seen.Add(scope) 17 | } 18 | 19 | return seen 20 | } 21 | 22 | func (m Map) Add(scope msgraph.PermissionScope) { 23 | name := *scope.Value 24 | 25 | if _, found := m[name]; !found { 26 | m[name] = scope 27 | } 28 | } 29 | 30 | func (m Map) ToSlice() []msgraph.PermissionScope { 31 | scopes := make([]msgraph.PermissionScope, 0) 32 | 33 | for _, scope := range m { 34 | scopes = append(scopes, scope) 35 | } 36 | 37 | return scopes 38 | } 39 | 40 | // ToCreate returns a Map describing the desired, non-existing scopes to be created. 41 | func (m Map) ToCreate(desired permissions.Permissions) Map { 42 | toCreate := make(Map) 43 | 44 | // ensure default PermissionScope is created if it doesn't exist 45 | if _, found := m[permissions.DefaultPermissionScopeValue]; !found { 46 | toCreate[permissions.DefaultPermissionScopeValue] = DefaultScope() 47 | } 48 | 49 | for _, scope := range desired { 50 | if scope.Name == permissions.DefaultPermissionScopeValue { 51 | continue 52 | } 53 | 54 | if _, found := m[scope.Name]; !found { 55 | toCreate[scope.Name] = FromPermission(scope) 56 | } 57 | } 58 | 59 | return toCreate 60 | } 61 | 62 | // ToDisable returns a Map describing the existing, non-desired scopes to be disabled. 63 | func (m Map) ToDisable(desired permissions.Permissions) Map { 64 | toDisable := make(Map) 65 | 66 | for _, scope := range m { 67 | name := *scope.Value 68 | if _, found := desired[name]; !found { 69 | disabledScope := scope 70 | disabledScope.IsEnabled = ptr.Bool(false) 71 | toDisable[name] = disabledScope 72 | } 73 | } 74 | 75 | // ensure default PermissionScope is not disabled 76 | delete(toDisable, permissions.DefaultPermissionScopeValue) 77 | return toDisable 78 | } 79 | 80 | // Unmodified returns a Map describing existing scopes that should not be modified. 81 | // I.e. the difference of (existing - (toCreate + toDisable)) 82 | func (m Map) Unmodified(toCreate, toDisable Map) Map { 83 | unmodified := make(Map) 84 | 85 | for _, scope := range m { 86 | name := *scope.Value 87 | id := *scope.ID 88 | 89 | _, foundToCreate := toCreate[name] 90 | _, foundToDisable := toDisable[name] 91 | 92 | if foundToCreate || foundToDisable { 93 | continue 94 | } 95 | 96 | unmodified[name] = New(id, name) 97 | } 98 | 99 | return unmodified 100 | } 101 | 102 | func (m Map) ToPermissionList() permissions.PermissionList { 103 | result := make(permissions.PermissionList, 0) 104 | 105 | for _, scope := range m { 106 | result = append(result, permissions.FromPermissionScope(scope)) 107 | } 108 | 109 | return result 110 | } 111 | -------------------------------------------------------------------------------- /pkg/azure/fake/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | msgraphlib "github.com/nais/msgraph.go/v1.0" 5 | 6 | "github.com/nais/azureator/pkg/azure" 7 | "github.com/nais/azureator/pkg/azure/credentials" 8 | "github.com/nais/azureator/pkg/azure/fake" 9 | fakemsgraph "github.com/nais/azureator/pkg/azure/fake/msgraph" 10 | "github.com/nais/azureator/pkg/azure/result" 11 | "github.com/nais/azureator/pkg/transaction" 12 | ) 13 | 14 | type fakeAzureClient struct{} 15 | 16 | type fakeAzureCredentialsClient struct{} 17 | 18 | const ( 19 | ApplicationNotExistsName = "not-exists-in-azure" 20 | ApplicationExists = "exists-in-azure" 21 | ) 22 | 23 | func (a fakeAzureClient) Create(tx transaction.Transaction) (*result.Application, error) { 24 | internalApp := fake.AzureApplicationResult(tx.Instance, result.OperationCreated) 25 | return &internalApp, nil 26 | } 27 | 28 | func (a fakeAzureClient) Delete(transaction.Transaction) error { 29 | return nil 30 | } 31 | 32 | func (a fakeAzureClient) Exists(tx transaction.Transaction) (*msgraphlib.Application, bool, error) { 33 | appExists := tx.Instance.Name == ApplicationExists 34 | validStatus := len(tx.Instance.GetObjectId()) > 0 && len(tx.Instance.GetClientId()) > 0 35 | if appExists || validStatus { 36 | app := fakemsgraph.Application(tx) 37 | return &app, true, nil 38 | } 39 | return nil, false, nil 40 | } 41 | 42 | func (a fakeAzureClient) Get(tx transaction.Transaction) (msgraphlib.Application, error) { 43 | return fakemsgraph.Application(tx), nil 44 | } 45 | 46 | func (a fakeAzureClient) GetServicePrincipal(tx transaction.Transaction) (msgraphlib.ServicePrincipal, error) { 47 | return fakemsgraph.ServicePrincipal(tx), nil 48 | } 49 | 50 | func (a fakeAzureClient) GetPreAuthorizedApps(tx transaction.Transaction) (*result.PreAuthorizedApps, error) { 51 | return fake.AzurePreAuthorizedApps(tx.Instance), nil 52 | } 53 | 54 | func (a fakeAzureClient) Credentials() azure.Credentials { 55 | return fakeAzureCredentialsClient{} 56 | } 57 | 58 | func (a fakeAzureCredentialsClient) Add(tx transaction.Transaction) (credentials.Set, error) { 59 | return fake.AzureCredentialsSet(tx.Instance, tx.ClusterName), nil 60 | } 61 | 62 | func (a fakeAzureCredentialsClient) DeleteExpired(tx transaction.Transaction) error { 63 | return nil 64 | } 65 | 66 | func (a fakeAzureCredentialsClient) DeleteUnused(tx transaction.Transaction) error { 67 | return nil 68 | } 69 | 70 | func (a fakeAzureCredentialsClient) Rotate(tx transaction.Transaction) (credentials.Set, error) { 71 | newSet := fake.AzureCredentialsSet(tx.Instance, tx.ClusterName) 72 | newSet.Current = tx.Secrets.LatestCredentials.Set.Next 73 | return newSet, nil 74 | } 75 | 76 | func (a fakeAzureCredentialsClient) Purge(tx transaction.Transaction) error { 77 | return nil 78 | } 79 | 80 | func (a fakeAzureCredentialsClient) Validate(tx transaction.Transaction, existing credentials.Set) (bool, error) { 81 | return true, nil 82 | } 83 | 84 | func (a fakeAzureClient) Update(tx transaction.Transaction) (*result.Application, error) { 85 | internalApp := fake.AzureApplicationResult(tx.Instance, result.OperationUpdated) 86 | return &internalApp, nil 87 | } 88 | 89 | func NewFakeAzureClient() azure.Client { 90 | return fakeAzureClient{} 91 | } 92 | -------------------------------------------------------------------------------- /pkg/reconciler/finalizer/finalizer.go: -------------------------------------------------------------------------------- 1 | package finalizer 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/nais/azureator/pkg/annotations" 7 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 8 | corev1 "k8s.io/api/core/v1" 9 | "sigs.k8s.io/controller-runtime/pkg/client" 10 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 11 | 12 | "github.com/nais/azureator/pkg/metrics" 13 | "github.com/nais/azureator/pkg/reconciler" 14 | "github.com/nais/azureator/pkg/transaction" 15 | ) 16 | 17 | const ( 18 | Name string = "azure.nais.io/finalizer" 19 | // OldName is not domain-qualified and triggers a warning from the API server, use FinalizerName instead. 20 | // TODO: remove once no instances with the old finalizer exist. 21 | OldName string = "finalizer.azurerator.nais.io" 22 | ) 23 | 24 | type finalizer struct { 25 | reconciler.AzureAdApplication 26 | client client.Client 27 | } 28 | 29 | func NewFinalizer(reconciler reconciler.AzureAdApplication, client client.Client) reconciler.Finalizer { 30 | return finalizer{ 31 | AzureAdApplication: reconciler, 32 | client: client, 33 | } 34 | } 35 | 36 | func (f finalizer) Process(tx transaction.Transaction) (bool, error) { 37 | hasFinalizer := controllerutil.ContainsFinalizer(tx.Instance, Name) 38 | hasOldFinalizer := controllerutil.ContainsFinalizer(tx.Instance, OldName) 39 | shouldFinalize := !tx.Instance.GetDeletionTimestamp().IsZero() 40 | 41 | if (hasFinalizer || hasOldFinalizer) && shouldFinalize { 42 | return true, f.finalize(tx) 43 | } 44 | 45 | if !hasFinalizer { 46 | return true, f.register(tx) 47 | } 48 | 49 | return false, nil 50 | } 51 | 52 | func (f finalizer) register(tx transaction.Transaction) error { 53 | tx.Logger.Debug("finalizer for object not found, registering...") 54 | 55 | err := f.UpdateApplication(tx.Ctx, tx.Instance, func(existing *v1.AzureAdApplication) error { 56 | controllerutil.AddFinalizer(existing, Name) 57 | return f.client.Update(tx.Ctx, existing) 58 | }) 59 | if err != nil { 60 | return fmt.Errorf("error when registering finalizer: %w", err) 61 | } 62 | 63 | return nil 64 | } 65 | 66 | func (f finalizer) finalize(tx transaction.Transaction) error { 67 | tx.Logger.Debug("finalizer triggered, deleting resources...") 68 | 69 | _, shouldPreserve := annotations.HasAnnotation(tx.Instance, annotations.PreserveKey) 70 | if shouldPreserve { 71 | err := f.Azure().PurgeCredentials(tx) 72 | if err != nil { 73 | return fmt.Errorf("purging credentials from Azure AD: %w", err) 74 | } 75 | } else { 76 | err := f.Azure().Delete(tx) 77 | if err != nil { 78 | return fmt.Errorf("failed to delete resources: %w", err) 79 | } 80 | 81 | f.ReportEvent(tx, corev1.EventTypeNormal, v1.EventDeletedInAzure, "Azure application is deleted") 82 | } 83 | 84 | err := f.UpdateApplication(tx.Ctx, tx.Instance, func(existing *v1.AzureAdApplication) error { 85 | controllerutil.RemoveFinalizer(existing, Name) 86 | // TODO: remove once old finalizer is no longer in use 87 | controllerutil.RemoveFinalizer(existing, OldName) 88 | return f.client.Update(tx.Ctx, existing) 89 | }) 90 | if err != nil { 91 | return fmt.Errorf("failed to remove finalizer from list: %w", err) 92 | } 93 | 94 | metrics.IncWithNamespaceLabel(metrics.AzureAppsDeletedCount, tx.Instance.Namespace) 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /pkg/synchronizer/synchronizer_test.go: -------------------------------------------------------------------------------- 1 | package synchronizer 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nais/azureator/pkg/event" 7 | "github.com/nais/azureator/pkg/fixtures" 8 | nais_io_v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 9 | "github.com/stretchr/testify/assert" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | func TestHasMatchingPreAuthorizedApp(t *testing.T) { 14 | clusterName := "test-cluster" 15 | e := event.New("1", event.Created, &metav1.ObjectMeta{ 16 | Name: "some-app", 17 | Namespace: "test-namespace", 18 | }, clusterName) 19 | 20 | for _, test := range []struct { 21 | name string 22 | rule nais_io_v1.AccessPolicyRule 23 | expected bool 24 | }{ 25 | { 26 | name: "no rule", 27 | rule: nais_io_v1.AccessPolicyRule{}, 28 | expected: false, 29 | }, 30 | { 31 | name: "non-matching app", 32 | rule: nais_io_v1.AccessPolicyRule{ 33 | Application: "another-app", 34 | }, 35 | expected: false, 36 | }, 37 | { 38 | name: "non-matching namespace", 39 | rule: nais_io_v1.AccessPolicyRule{ 40 | Application: "some-app", 41 | Namespace: "another-namespace", 42 | }, 43 | expected: false, 44 | }, 45 | { 46 | name: "non-matching cluster", 47 | rule: nais_io_v1.AccessPolicyRule{ 48 | Application: "some-app", 49 | Cluster: "another-cluster", 50 | }, 51 | expected: false, 52 | }, 53 | { 54 | name: "non-matching namespace and cluster", 55 | rule: nais_io_v1.AccessPolicyRule{ 56 | Application: "some-app", 57 | Namespace: "another-namespace", 58 | Cluster: "another-cluster", 59 | }, 60 | expected: false, 61 | }, 62 | { 63 | name: "no matching fields", 64 | rule: nais_io_v1.AccessPolicyRule{ 65 | Application: "another-app", 66 | Namespace: "another-namespace", 67 | Cluster: "another-cluster", 68 | }, 69 | expected: false, 70 | }, 71 | { 72 | name: "all fields matching", 73 | rule: nais_io_v1.AccessPolicyRule{ 74 | Application: "some-app", 75 | Namespace: "test-namespace", 76 | Cluster: "test-cluster", 77 | }, 78 | expected: true, 79 | }, 80 | { 81 | name: "matching app and namespace, omitted cluster", 82 | rule: nais_io_v1.AccessPolicyRule{ 83 | Application: "some-app", 84 | Namespace: "test-namespace", 85 | }, 86 | expected: true, 87 | }, 88 | { 89 | name: "matching app and cluster, omitted namespace", 90 | rule: nais_io_v1.AccessPolicyRule{ 91 | Application: "some-app", 92 | Cluster: "test-cluster", 93 | }, 94 | expected: true, 95 | }, 96 | { 97 | name: "matching app, omitted cluster and namespace", 98 | rule: nais_io_v1.AccessPolicyRule{ 99 | Application: "some-app", 100 | }, 101 | expected: true, 102 | }, 103 | } { 104 | t.Run(test.name, func(t *testing.T) { 105 | app := fixtures.MinimalApplication() 106 | app.Spec.PreAuthorizedApplications = []nais_io_v1.AccessPolicyInboundRule{{AccessPolicyRule: test.rule}} 107 | 108 | actual := hasMatchingPreAuthorizedApp(*app, clusterName, e) 109 | if test.expected { 110 | assert.True(t, actual) 111 | } else { 112 | assert.False(t, actual) 113 | } 114 | }) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /pkg/kafka/consumer.go: -------------------------------------------------------------------------------- 1 | package kafka 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "time" 10 | 11 | "github.com/IBM/sarama" 12 | log "github.com/sirupsen/logrus" 13 | 14 | "github.com/nais/azureator/pkg/config" 15 | ) 16 | 17 | type Callback func(message *sarama.ConsumerMessage, logger *log.Entry) (retry bool, err error) 18 | 19 | var _ sarama.ConsumerGroupHandler = (*Consumer)(nil) 20 | 21 | type Consumer struct { 22 | callback Callback 23 | logger *log.Logger 24 | retryInterval time.Duration 25 | } 26 | 27 | // Setup is run at the beginning of a new session, before ConsumeClaim 28 | func (c *Consumer) Setup(_ sarama.ConsumerGroupSession) error { 29 | return nil 30 | } 31 | 32 | // Cleanup is run at the end of a session, once all ConsumeClaim goroutines have exited 33 | func (c *Consumer) Cleanup(_ sarama.ConsumerGroupSession) error { 34 | return nil 35 | } 36 | 37 | // ConsumeClaim must start a consumer loop of ConsumerGroupClaim's Messages(). 38 | func (c *Consumer) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error { 39 | retry := true 40 | var err error 41 | 42 | for message := range claim.Messages() { 43 | for retry { 44 | logger := c.logger.WithFields(log.Fields{ 45 | "kafka_offset": message.Offset, 46 | "kafka_partition": message.Partition, 47 | "kafka_topic": message.Topic, 48 | }) 49 | retry, err = c.callback(message, logger) 50 | if err != nil { 51 | logger.Errorf("consuming Kafka message: %s", err) 52 | if retry { 53 | time.Sleep(c.retryInterval) 54 | } 55 | } 56 | } 57 | retry, err = true, nil 58 | session.MarkMessage(message, "") 59 | } 60 | return nil 61 | } 62 | 63 | func NewConsumer(ctx context.Context, cfg config.Config, tlsConfig *tls.Config, logger *log.Logger, callback Callback) (*Consumer, error) { 64 | consumerCfg := sarama.NewConfig() 65 | consumerCfg.Net.TLS.Enable = cfg.Kafka.TLS.Enabled 66 | consumerCfg.Net.TLS.Config = tlsConfig 67 | consumerCfg.Version = sarama.V3_1_0_0 68 | consumerCfg.Consumer.Offsets.Initial = sarama.OffsetNewest 69 | consumerCfg.Consumer.MaxProcessingTime = cfg.Kafka.MaxProcessingTime 70 | consumerCfg.ClientID, _ = os.Hostname() 71 | sarama.Logger = logger 72 | 73 | groupID := fmt.Sprintf("azurerator-%s-%s-v1", cfg.ClusterName, cfg.Azure.Tenant.Id) 74 | 75 | group, err := sarama.NewConsumerGroup(cfg.Kafka.Brokers, groupID, consumerCfg) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | c := &Consumer{ 81 | callback: callback, 82 | logger: logger, 83 | retryInterval: cfg.Kafka.RetryInterval, 84 | } 85 | 86 | go func() { 87 | for err := range group.Errors() { 88 | c.logger.Errorf("Consumer encountered error: %s", err) 89 | } 90 | }() 91 | 92 | go func() { 93 | for { 94 | c.logger.Infof("(re-)starting consumer on topic %s", cfg.Kafka.Topic) 95 | err := group.Consume(ctx, []string{cfg.Kafka.Topic}, c) 96 | if err != nil { 97 | c.logger.Errorf("Error consuming: %s", err) 98 | } 99 | 100 | // check if context was cancelled, signaling that the consumer should stop 101 | if errors.Is(ctx.Err(), context.Canceled) { 102 | c.logger.Debug("Consumer context cancelled, stopping consumer") 103 | return 104 | } 105 | 106 | time.Sleep(10 * time.Second) 107 | } 108 | }() 109 | 110 | return c, nil 111 | } 112 | -------------------------------------------------------------------------------- /pkg/azure/client/application/optionalclaims/optionalclaims_test.go: -------------------------------------------------------------------------------- 1 | package optionalclaims_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nais/msgraph.go/ptr" 7 | msgraph "github.com/nais/msgraph.go/v1.0" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/nais/azureator/pkg/azure/client/application/optionalclaims" 11 | "github.com/nais/azureator/pkg/azure/util" 12 | ) 13 | 14 | func TestOptionalClaims_DescribeCreate(t *testing.T) { 15 | desired := msgraph.OptionalClaims{ 16 | AccessToken: []msgraph.OptionalClaim{ 17 | { 18 | Essential: ptr.Bool(true), 19 | Name: ptr.String("idtyp"), 20 | }, 21 | }, 22 | IDToken: []msgraph.OptionalClaim{ 23 | { 24 | Essential: ptr.Bool(true), 25 | Name: ptr.String("sid"), 26 | }, 27 | }, 28 | } 29 | 30 | create := optionalclaims.NewOptionalClaims().DescribeCreate() 31 | assert.Equal(t, desired, *create) 32 | } 33 | 34 | func TestOptionalClaims_DescribeUpdate(t *testing.T) { 35 | for _, test := range []struct { 36 | name string 37 | existing msgraph.OptionalClaims 38 | want msgraph.OptionalClaims 39 | }{ 40 | { 41 | name: "no existing optional claims", 42 | existing: msgraph.OptionalClaims{}, 43 | want: msgraph.OptionalClaims{ 44 | AccessToken: []msgraph.OptionalClaim{ 45 | { 46 | Essential: ptr.Bool(true), 47 | Name: ptr.String("idtyp"), 48 | }, 49 | }, 50 | IDToken: []msgraph.OptionalClaim{ 51 | { 52 | Essential: ptr.Bool(true), 53 | Name: ptr.String("sid"), 54 | }, 55 | }, 56 | }, 57 | }, 58 | { 59 | name: "existing non-conflicting optional claims", 60 | existing: msgraph.OptionalClaims{ 61 | AccessToken: []msgraph.OptionalClaim{ 62 | { 63 | Essential: ptr.Bool(true), 64 | Name: ptr.String("upn"), 65 | }, 66 | }, 67 | IDToken: []msgraph.OptionalClaim{ 68 | { 69 | Essential: ptr.Bool(false), 70 | Name: ptr.String("upn"), 71 | }, 72 | }, 73 | }, 74 | want: msgraph.OptionalClaims{ 75 | AccessToken: []msgraph.OptionalClaim{ 76 | { 77 | Essential: ptr.Bool(true), 78 | Name: ptr.String("upn"), 79 | }, 80 | { 81 | Essential: ptr.Bool(true), 82 | Name: ptr.String("idtyp"), 83 | }, 84 | }, 85 | IDToken: []msgraph.OptionalClaim{ 86 | { 87 | Essential: ptr.Bool(false), 88 | Name: ptr.String("upn"), 89 | }, 90 | { 91 | Essential: ptr.Bool(true), 92 | Name: ptr.String("sid"), 93 | }, 94 | }, 95 | }, 96 | }, 97 | { 98 | name: "existing conflicting optional claims", 99 | existing: msgraph.OptionalClaims{ 100 | AccessToken: []msgraph.OptionalClaim{ 101 | { 102 | Essential: ptr.Bool(true), 103 | Name: ptr.String("idtyp"), 104 | }, 105 | }, 106 | IDToken: []msgraph.OptionalClaim{ 107 | { 108 | Essential: ptr.Bool(false), 109 | Name: ptr.String("sid"), 110 | }, 111 | }, 112 | }, 113 | want: msgraph.OptionalClaims{ 114 | AccessToken: []msgraph.OptionalClaim{ 115 | { 116 | Essential: ptr.Bool(true), 117 | Name: ptr.String("idtyp"), 118 | }, 119 | }, 120 | IDToken: []msgraph.OptionalClaim{ 121 | { 122 | Essential: ptr.Bool(true), 123 | Name: ptr.String("sid"), 124 | }, 125 | }, 126 | }, 127 | }, 128 | } { 129 | t.Run(test.name, func(t *testing.T) { 130 | existingApp := util.EmptyApplication().OptionalClaims(&test.existing).Build() 131 | actual := optionalclaims.NewOptionalClaims().DescribeUpdate(*existingApp) 132 | assert.Equal(t, test.want, *actual) 133 | }) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /pkg/azure/fake/application.go: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/google/uuid" 7 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 8 | "github.com/nais/liberator/pkg/kubernetes" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | 11 | "github.com/nais/azureator/pkg/azure/credentials" 12 | "github.com/nais/azureator/pkg/azure/resource" 13 | "github.com/nais/azureator/pkg/azure/result" 14 | "github.com/nais/azureator/pkg/customresources" 15 | "github.com/nais/azureator/pkg/util/crypto" 16 | ) 17 | 18 | func AzureApplicationResult(instance *v1.AzureAdApplication, operation result.Operation) result.Application { 19 | objectId := GetOrGenerate(instance.GetObjectId()) 20 | clientId := GetOrGenerate(instance.GetClientId()) 21 | servicePrincipalId := GetOrGenerate(instance.GetServicePrincipalId()) 22 | 23 | tenantId := uuid.New().String() 24 | 25 | return result.Application{ 26 | ClientId: clientId, 27 | ObjectId: objectId, 28 | ServicePrincipalId: servicePrincipalId, 29 | PreAuthorizedApps: mapToInternalPreAuthApps(instance.Spec.PreAuthorizedApplications), 30 | Tenant: tenantId, 31 | Result: operation, 32 | } 33 | } 34 | 35 | func AzureCredentialsSet(instance *v1.AzureAdApplication, clusterName string) credentials.Set { 36 | currJwk, err := crypto.GenerateJwk(instance, clusterName) 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | nextJwk, err := crypto.GenerateJwk(instance, clusterName) 42 | if err != nil { 43 | panic(err) 44 | } 45 | 46 | return credentials.Set{ 47 | Current: credentials.Credentials{ 48 | Certificate: credentials.Certificate{ 49 | KeyId: uuid.New().String(), 50 | Jwk: currJwk, 51 | }, 52 | Password: credentials.Password{ 53 | KeyId: uuid.New().String(), 54 | ClientSecret: uuid.New().String(), 55 | }, 56 | }, 57 | Next: credentials.Credentials{ 58 | Certificate: credentials.Certificate{ 59 | KeyId: uuid.New().String(), 60 | Jwk: nextJwk, 61 | }, 62 | Password: credentials.Password{ 63 | KeyId: uuid.New().String(), 64 | ClientSecret: uuid.New().String(), 65 | }, 66 | }, 67 | } 68 | } 69 | 70 | func AzurePreAuthorizedApps(instance *v1.AzureAdApplication) *result.PreAuthorizedApps { 71 | preAuthApps := mapToInternalPreAuthApps(instance.Spec.PreAuthorizedApplications) 72 | return &preAuthApps 73 | } 74 | 75 | func mapToInternalPreAuthApps(apps []v1.AccessPolicyInboundRule) result.PreAuthorizedApps { 76 | valid := make([]resource.Resource, 0) 77 | invalid := make([]resource.Resource, 0) 78 | 79 | for _, app := range apps { 80 | if strings.Contains(customresources.GetUniqueName(app.AccessPolicyRule), "invalid") { 81 | invalid = append(invalid, mapToInternalPreAuthApp(app)) 82 | } else { 83 | valid = append(valid, mapToInternalPreAuthApp(app)) 84 | } 85 | } 86 | 87 | return result.PreAuthorizedApps{ 88 | Valid: valid, 89 | Invalid: invalid, 90 | } 91 | } 92 | 93 | func mapToInternalPreAuthApp(app v1.AccessPolicyInboundRule) resource.Resource { 94 | clientId := uuid.New().String() 95 | objectId := uuid.New().String() 96 | name := GetOrGenerate(kubernetes.UniformResourceName(&metav1.ObjectMeta{ 97 | Name: app.Application, 98 | Namespace: app.Namespace, 99 | }, app.Cluster)) 100 | return resource.Resource{ 101 | Name: name, 102 | ClientId: clientId, 103 | ObjectId: objectId, 104 | PrincipalType: resource.PrincipalTypeServicePrincipal, 105 | AccessPolicyInboundRule: app, 106 | } 107 | } 108 | 109 | func GetOrGenerate(field string) string { 110 | if len(field) > 0 { 111 | return field 112 | } else { 113 | return uuid.New().String() 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /charts/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Secret 4 | type: kubernetes.io/Opaque 5 | metadata: 6 | name: {{ include "azurerator.fullname" . }}-env 7 | annotations: 8 | reloader.stakater.com/match: "true" 9 | labels: 10 | {{ include "azurerator.labels" . | nindent 4 }} 11 | stringData: 12 | azurerator.yaml: | 13 | azure: 14 | auth: 15 | client-id: "{{ .Values.azure.clientID | required ".Values.azure.clientID is required." }}" 16 | {{- if .Values.global.google.federatedAuth | default .Values.google.federatedAuth }} 17 | client-secret: "{{ .Values.azure.clientSecret | default "n/a" }}" 18 | {{ else }} 19 | client-secret: "{{ .Values.azure.clientSecret | required ".Values.azure.clientSecret is required." }}" 20 | {{ end }} 21 | {{- if .Values.global.google.federatedAuth | default .Values.google.federatedAuth }} 22 | google: 23 | enabled: "{{ .Values.global.google.federatedAuth | default .Values.google.federatedAuth }}" 24 | project-id: "{{ .Values.global.google.projectID | default .Values.google.projectID | required ".Values.google.projectID is required." }}" 25 | {{ end }} 26 | features: 27 | app-role-assignment-required: 28 | enabled: "{{ .Values.global.features.appRoleAssignmentRequired | default .Values.features.appRoleAssignmentRequired }}" 29 | claims-mapping-policies: 30 | enabled: "{{ .Values.global.features.claimsMappingPolicies.enabled | default .Values.features.claimsMappingPolicies.enabled }}" 31 | id: "{{ .Values.features.claimsMappingPolicies.id }}" 32 | cleanup-orphans: 33 | enabled: "{{ .Values.global.features.cleanupOrphans | default .Values.features.cleanupOrphans }}" 34 | custom-security-attributes: 35 | enabled: "{{ .Values.global.features.customSecurityAttributes.enabled | default .Values.features.customSecurityAttributes.enabled }}" 36 | groups-assignment: 37 | enabled: "{{ .Values.global.features.groupsAssignment.enabled | default .Values.features.groupsAssignment.enabled }}" 38 | {{- if .Values.features.groupsAssignment.allUsersGroupIDs }} 39 | all-users-group-id: 40 | {{- range $val := .Values.features.groupsAssignment.allUsersGroupIDs }} 41 | - "{{ $val }}" 42 | {{- end }} 43 | {{- end }} 44 | permissiongrant-resource-id: "{{ .Values.azure.permissionGrantResourceID | required ".Values.azure.permissionGrantResourceID is required." }}" 45 | tenant: 46 | id: "{{ .Values.azure.tenant.id | required ".Values.azure.tenant.id is required." }}" 47 | name: "{{ .Values.azure.tenant.name | required ".Values.azure.tenant.name is required." }}" 48 | cluster-name: "{{ .Values.global.clusterName | default .Values.clusterName | required ".Values.clusterName is required." }}" 49 | controller: 50 | max-concurrent-reconciles: "{{ .Values.global.controller.maxConcurrentReconciles | default .Values.controller.maxConcurrentReconciles }}" 51 | kafka: 52 | enabled: "{{ .Values.global.kafka.application | default .Values.kafka.application }}" 53 | topic: "{{ .Release.Namespace }}.{{ include "azurerator.fullname" . }}" 54 | tls: 55 | enabled: "{{ .Values.global.kafka.tls | default .Values.kafka.tls }}" 56 | leader-election: 57 | enabled: "{{ .Values.global.controller.leaderElection | default .Values.controller.leaderElection }}" 58 | secret-rotation: 59 | cleanup: "{{ .Values.global.controller.secretRotation | default .Values.controller.secretRotation }}" 60 | max-age: "{{ .Values.global.controller.secretRotationMaxAge | default .Values.controller.secretRotationMaxAge }}" 61 | validations: 62 | tenant: 63 | required: "{{ .Values.controller.tenantNameStrictMatching }}" 64 | -------------------------------------------------------------------------------- /pkg/azure/client/application/approle/approle_test.go: -------------------------------------------------------------------------------- 1 | package approle_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/uuid" 7 | "github.com/nais/msgraph.go/ptr" 8 | msgraph "github.com/nais/msgraph.go/v1.0" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/nais/azureator/pkg/azure/client/application/approle" 12 | "github.com/nais/azureator/pkg/azure/permissions" 13 | "github.com/nais/azureator/pkg/azure/util" 14 | ) 15 | 16 | func TestNew(t *testing.T) { 17 | name := "role1" 18 | id := msgraph.UUID(uuid.New().String()) 19 | 20 | expected := msgraph.AppRole{ 21 | AllowedMemberTypes: []string{"Application"}, 22 | Description: ptr.String(name), 23 | DisplayName: ptr.String(name), 24 | ID: &id, 25 | IsEnabled: ptr.Bool(true), 26 | Value: ptr.String(name), 27 | } 28 | actual := approle.New(id, name) 29 | 30 | assert.Equal(t, expected, actual) 31 | } 32 | 33 | func TestNewGenerateId(t *testing.T) { 34 | name := "role1" 35 | actual := approle.NewGenerateId(name) 36 | id := actual.ID 37 | 38 | expected := msgraph.AppRole{ 39 | AllowedMemberTypes: []string{"Application"}, 40 | Description: ptr.String(name), 41 | DisplayName: ptr.String(name), 42 | ID: id, 43 | IsEnabled: ptr.Bool(true), 44 | Value: ptr.String(name), 45 | } 46 | 47 | assert.Equal(t, expected, actual) 48 | } 49 | 50 | func TestDefaultRole(t *testing.T) { 51 | id := msgraph.UUID(permissions.DefaultAppRoleId) 52 | expected := msgraph.AppRole{ 53 | AllowedMemberTypes: []string{"Application"}, 54 | Description: ptr.String(permissions.DefaultAppRoleValue), 55 | DisplayName: ptr.String(permissions.DefaultAppRoleValue), 56 | ID: &id, 57 | IsEnabled: ptr.Bool(true), 58 | Value: ptr.String(permissions.DefaultAppRoleValue), 59 | } 60 | actual := approle.DefaultRole() 61 | 62 | assert.Equal(t, expected, actual) 63 | } 64 | 65 | func TestDefaultGroupRole(t *testing.T) { 66 | id := msgraph.UUID(permissions.DefaultGroupRoleId) 67 | expected := msgraph.AppRole{ 68 | AllowedMemberTypes: []string{"Application"}, 69 | Description: ptr.String(permissions.DefaultGroupRoleValue), 70 | DisplayName: ptr.String(permissions.DefaultGroupRoleValue), 71 | ID: &id, 72 | IsEnabled: ptr.Bool(true), 73 | Value: ptr.String(permissions.DefaultGroupRoleValue), 74 | } 75 | actual := approle.DefaultGroupRole() 76 | 77 | assert.Equal(t, expected, actual) 78 | } 79 | 80 | func TestEnsureDefaultAppRoleIsEnabled(t *testing.T) { 81 | defaultRole := approle.DefaultRole() 82 | defaultRole.IsEnabled = ptr.Bool(false) 83 | 84 | roles := []msgraph.AppRole{defaultRole} 85 | for _, role := range roles { 86 | assert.False(t, *role.IsEnabled) 87 | } 88 | 89 | actual := approle.EnsureDefaultAppRoleIsEnabled(roles) 90 | for _, role := range actual { 91 | assert.True(t, *role.IsEnabled) 92 | } 93 | } 94 | 95 | func TestFromPermission(t *testing.T) { 96 | permission := permissions.NewGenerateIdEnabled("role") 97 | role := approle.FromPermission(permission) 98 | 99 | assert.Equal(t, "role", *role.Description) 100 | assert.Equal(t, "role", *role.DisplayName) 101 | assert.Equal(t, "role", *role.Value) 102 | assert.Equal(t, permission.ID, *role.ID) 103 | } 104 | 105 | func TestRemoveDisabled(t *testing.T) { 106 | enabledRole := approle.NewGenerateId("enabled-role") 107 | enabledRole2 := approle.NewGenerateId("enabled-role-2") 108 | disabledRole := approle.NewGenerateId("disabled-role") 109 | disabledRole.IsEnabled = ptr.Bool(false) 110 | 111 | roles := []msgraph.AppRole{ 112 | enabledRole, 113 | enabledRole2, 114 | disabledRole, 115 | } 116 | application := util.EmptyApplication(). 117 | AppRoles(roles). 118 | Build() 119 | 120 | desired := approle.RemoveDisabled(*application) 121 | assert.Len(t, desired, 2) 122 | assert.Contains(t, desired, enabledRole) 123 | assert.Contains(t, desired, enabledRole2) 124 | assert.NotContains(t, desired, disabledRole) 125 | } 126 | -------------------------------------------------------------------------------- /pkg/azure/client/serviceprincipal/policies.go: -------------------------------------------------------------------------------- 1 | package serviceprincipal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | msgraph "github.com/nais/msgraph.go/v1.0" 9 | 10 | "github.com/nais/azureator/pkg/azure" 11 | "github.com/nais/azureator/pkg/transaction" 12 | ) 13 | 14 | type Policies interface { 15 | Process(tx transaction.Transaction, policyID string) error 16 | } 17 | 18 | type policies struct { 19 | azure.RuntimeClient 20 | } 21 | 22 | type ClaimsMappingPolicyBody struct { 23 | Content string `json:"@odata.id"` 24 | } 25 | 26 | func NewClaimsMappingPolicyBody(id string) ClaimsMappingPolicyBody { 27 | return ClaimsMappingPolicyBody{ 28 | Content: fmt.Sprintf("https://graph.microsoft.com/v1.0/policies/claimsMappingPolicies/%s", id), 29 | } 30 | } 31 | 32 | func newPolicies(client azure.RuntimeClient) Policies { 33 | return &policies{ 34 | RuntimeClient: client, 35 | } 36 | } 37 | 38 | func (p *policies) Process(tx transaction.Transaction, desiredPolicyID string) error { 39 | if desiredPolicyID == "" { 40 | tx.Logger.Debug("claims-mapping-policies: desiredPolicyID is empty; skipping...") 41 | return nil 42 | } 43 | 44 | servicePrincipalID := tx.Instance.GetServicePrincipalId() 45 | if len(servicePrincipalID) == 0 { 46 | return fmt.Errorf("claims-mapping-policies: service principal ID is not set") 47 | } 48 | 49 | assignedPolicies, err := p.getAssignedPolicies(tx.Ctx, servicePrincipalID) 50 | if err != nil { 51 | return fmt.Errorf("claims-mapping-policies: fetching existing policies for service principal '%s': %w", servicePrincipalID, err) 52 | } 53 | 54 | // a ServicePrincipal can only have one assignedPolicy assigned at any given time, so we must first revoke any existing, non-matching policies 55 | for _, assignedPolicy := range assignedPolicies { 56 | assignedPolicyID, ok := policyID(assignedPolicy) 57 | if !ok { 58 | continue 59 | } 60 | 61 | // return early if the desired policy is already assigned 62 | if assignedPolicyID == desiredPolicyID { 63 | tx.Logger.Debugf("claims-mapping-policies: skipping assignment; '%s' already assigned to service principal '%s'", desiredPolicyID, servicePrincipalID) 64 | return nil 65 | } 66 | 67 | err := p.removePolicy(tx.Ctx, assignedPolicyID, servicePrincipalID) 68 | if err != nil { 69 | return fmt.Errorf("claims-mapping-policies: removing '%s' from service principal '%s': %w", assignedPolicyID, servicePrincipalID, err) 70 | } 71 | tx.Logger.Infof("claims-mapping-policies: successfully removed '%s' from service principal '%s'", assignedPolicyID, servicePrincipalID) 72 | } 73 | 74 | err = p.assignPolicy(tx.Ctx, desiredPolicyID, servicePrincipalID) 75 | if err != nil { 76 | return fmt.Errorf("claims-mapping-policies: assigning '%s' to service principal '%s': %w", desiredPolicyID, servicePrincipalID, err) 77 | } 78 | tx.Logger.Infof("claims-mapping-policies: successfully assigned '%s' to service principal '%s'", desiredPolicyID, servicePrincipalID) 79 | return nil 80 | } 81 | 82 | func (p *policies) assignPolicy(ctx context.Context, desiredPolicyID, servicePrincipalID string) error { 83 | return p.GraphClient(). 84 | ServicePrincipals(). 85 | ID(servicePrincipalID). 86 | ClaimsMappingPolicies(). 87 | Request(). 88 | JSONRequest(ctx, http.MethodPost, "/$ref", NewClaimsMappingPolicyBody(desiredPolicyID), nil) 89 | } 90 | 91 | func (p *policies) getAssignedPolicies(ctx context.Context, servicePrincipalID string) ([]msgraph.ClaimsMappingPolicy, error) { 92 | return p.GraphClient(). 93 | ServicePrincipals(). 94 | ID(servicePrincipalID). 95 | ClaimsMappingPolicies(). 96 | Request(). 97 | Get(ctx) 98 | } 99 | 100 | func (p *policies) removePolicy(ctx context.Context, assignedPolicyID, servicePrincipalID string) error { 101 | return p.GraphClient(). 102 | ServicePrincipals(). 103 | ID(servicePrincipalID). 104 | ClaimsMappingPolicies(). 105 | ID(assignedPolicyID). 106 | Request(). 107 | JSONRequest(ctx, http.MethodDelete, "/$ref", nil, nil) 108 | } 109 | 110 | func policyID(policy msgraph.ClaimsMappingPolicy) (string, bool) { 111 | if policy.ID != nil && *policy.ID != "" { 112 | return *policy.ID, true 113 | } 114 | 115 | return "", false 116 | } 117 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Build and deploy 2 | concurrency: 3 | group: ${{ github.workflow }}-${{ github.ref }} 4 | cancel-in-progress: true 5 | env: 6 | GOOGLE_REGISTRY: "europe-north1-docker.pkg.dev" 7 | FEATURE_NAME: "azurerator" 8 | on: 9 | push: 10 | paths-ignore: 11 | - "*.md" 12 | permissions: 13 | contents: read 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout latest code 19 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v6 20 | - name: Set up Go 21 | uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # ratchet:actions/setup-go@v6 22 | with: 23 | go-version-file: 'go.mod' 24 | - name: Setup Test 25 | run: | 26 | make setup-envtest 27 | - name: Check for vulnerable dependencies and static code 28 | run: | 29 | make check 30 | - name: Test Go 31 | run: | 32 | make test 33 | build_and_push: 34 | needs: test 35 | name: Publish to Google and GitHub registries 36 | if: github.ref == 'refs/heads/master' 37 | permissions: 38 | id-token: "write" 39 | packages: "write" 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: Checkout 43 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v6 44 | - name: Install cosign 45 | uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # ratchet:sigstore/cosign-installer@v4.0.0 46 | - name: Verify runner image 47 | run: cosign verify --certificate-oidc-issuer https://accounts.google.com --certificate-identity keyless@distroless.iam.gserviceaccount.com gcr.io/distroless/static-debian12:nonroot 48 | - uses: nais/platform-build-push-sign@8be8359cd90915318ee8ab5fbc8337d04937ae70 # ratchet:nais/platform-build-push-sign@main 49 | id: build_push_sign 50 | with: 51 | name: azurerator 52 | dockerfile: Dockerfile 53 | google_service_account: gh-azurerator 54 | push: true 55 | push_ghcr: true 56 | workload_identity_provider: ${{ secrets.NAIS_IO_WORKLOAD_IDENTITY_PROVIDER }} 57 | - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # ratchet:azure/setup-helm@v4 58 | with: 59 | version: "v3.12.2" 60 | - name: Package chart 61 | id: package_chart 62 | env: 63 | CHART_PATH: ./charts 64 | run: | 65 | base_version="$(yq '.version' < "${{ env.CHART_PATH }}/Chart.yaml")" 66 | chart_version="${base_version}-${{ steps.build_push_sign.outputs.version }}" 67 | 68 | yq eval \ 69 | '.version="'"$chart_version"'"' \ 70 | "${{ env.CHART_PATH }}/Chart.yaml" --inplace 71 | yq eval \ 72 | '.image.tag="${{ steps.build_push_sign.outputs.version }}"' \ 73 | "${{ env.CHART_PATH }}/values.yaml" --inplace 74 | 75 | # helm dependency update "${{ env.CHART_PATH }}" 76 | helm package "${{ env.CHART_PATH }}" --destination . 77 | 78 | name=$(yq '.name' < "${{ env.CHART_PATH }}/Chart.yaml") 79 | echo "name=$name" >> $GITHUB_OUTPUT 80 | echo "version=$chart_version" >> $GITHUB_OUTPUT 81 | echo "archive=$name-$chart_version.tgz" >> $GITHUB_OUTPUT 82 | - name: Push Chart 83 | run: |- 84 | chart="${{ steps.package_chart.outputs.archive }}" 85 | echo "Pushing: $chart" 86 | helm push "$chart" oci://${{ env.GOOGLE_REGISTRY }}/nais-io/nais/feature 87 | outputs: 88 | chart_name: ${{ steps.package_chart.outputs.name }} 89 | chart_version: ${{ steps.package_chart.outputs.version }} 90 | chart_archive: ${{ steps.package_chart.outputs.archive }} 91 | rollout: 92 | runs-on: fasit-deploy 93 | if: github.ref == 'refs/heads/master' 94 | permissions: 95 | id-token: write 96 | needs: 97 | - build_and_push 98 | steps: 99 | - uses: nais/fasit-deploy@8727ed1c7a5a465e837873e6016a9a692a6b874a # ratchet:nais/fasit-deploy@v2 100 | with: 101 | chart: oci://${{ env.GOOGLE_REGISTRY }}/nais-io/nais/feature/${{ needs.build_and_push.outputs.chart_name }} 102 | version: ${{ needs.build_and_push.outputs.chart_version }} 103 | -------------------------------------------------------------------------------- /pkg/customresources/azureadapplication_test.go: -------------------------------------------------------------------------------- 1 | package customresources_test 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | 11 | "github.com/nais/azureator/pkg/annotations" 12 | "github.com/nais/azureator/pkg/customresources" 13 | "github.com/nais/azureator/pkg/fixtures" 14 | ) 15 | 16 | func TestAzureAdApplication_IsHashChanged(t *testing.T) { 17 | t.Run("Application with unchanged spec should be synchronized", func(t *testing.T) { 18 | app := fixtures.MinimalApplication() 19 | actual, err := customresources.IsHashChanged(app) 20 | assert.NoError(t, err) 21 | assert.False(t, actual) 22 | }) 23 | t.Run("Application with changed spec should not be synchronized", func(t *testing.T) { 24 | app := fixtures.MinimalApplication() 25 | app.Spec.LogoutUrl = "yolo" 26 | actual, err := customresources.IsHashChanged(app) 27 | assert.NoError(t, err) 28 | assert.True(t, actual) 29 | }) 30 | } 31 | 32 | func TestIsSecretNameChanged(t *testing.T) { 33 | t.Run("Application with unchanged secret name", func(t *testing.T) { 34 | app := fixtures.MinimalApplication() 35 | shouldUpdate := customresources.SecretNameChanged(app) 36 | assert.False(t, shouldUpdate) 37 | }) 38 | 39 | t.Run("Application with changed secret name", func(t *testing.T) { 40 | app := fixtures.MinimalApplication() 41 | app.Spec.SecretName = "some-secret" 42 | shouldUpdate := customresources.SecretNameChanged(app) 43 | assert.True(t, shouldUpdate) 44 | }) 45 | 46 | t.Run("Application with not set synchronized secret name in status", func(t *testing.T) { 47 | app := fixtures.MinimalApplication() 48 | app.Status.SynchronizationSecretName = "" 49 | shouldUpdate := customresources.SecretNameChanged(app) 50 | assert.True(t, shouldUpdate) 51 | }) 52 | } 53 | 54 | func TestHasExpiredSecrets(t *testing.T) { 55 | t.Run("not set rotation time should return not expired", func(t *testing.T) { 56 | app := fixtures.MinimalApplication() 57 | app.Status.SynchronizationSecretRotationTime = nil 58 | 59 | shouldUpdate := customresources.HasExpiredSecrets(app, time.Minute) 60 | assert.False(t, shouldUpdate) 61 | }) 62 | 63 | t.Run("valid secret should return not expired", func(t *testing.T) { 64 | app := fixtures.MinimalApplication() 65 | shouldUpdate := customresources.HasExpiredSecrets(app, time.Minute) 66 | 67 | assert.False(t, shouldUpdate) 68 | }) 69 | 70 | t.Run("expired secret should return expired", func(t *testing.T) { 71 | app := fixtures.MinimalApplication() 72 | 73 | expiredTime := metav1.NewTime(metav1.Now().Add(-1 * time.Minute)) 74 | app.Status.SynchronizationSecretRotationTime = &expiredTime 75 | 76 | shouldUpdate := customresources.HasExpiredSecrets(app, time.Minute) 77 | assert.True(t, shouldUpdate) 78 | }) 79 | } 80 | 81 | func TestHasResynchronizeAnnotation(t *testing.T) { 82 | t.Run("not set annotation should not resynchronize", func(t *testing.T) { 83 | app := fixtures.MinimalApplication() 84 | 85 | hasAnnotation := customresources.HasResynchronizeAnnotation(app) 86 | assert.False(t, hasAnnotation) 87 | }) 88 | 89 | t.Run("set annotation should synchronize regardless of value", func(t *testing.T) { 90 | app := fixtures.MinimalApplication() 91 | annotations.SetAnnotation(app, annotations.ResynchronizeKey, strconv.FormatBool(false)) 92 | 93 | hasAnnotation := customresources.HasResynchronizeAnnotation(app) 94 | assert.True(t, hasAnnotation) 95 | 96 | app = fixtures.MinimalApplication() 97 | annotations.SetAnnotation(app, annotations.ResynchronizeKey, strconv.FormatBool(true)) 98 | 99 | hasAnnotation = customresources.HasResynchronizeAnnotation(app) 100 | assert.True(t, hasAnnotation) 101 | }) 102 | } 103 | 104 | func TestHasRotateAnnotation(t *testing.T) { 105 | t.Run("not set annotation should not rotate", func(t *testing.T) { 106 | app := fixtures.MinimalApplication() 107 | 108 | hasAnnotation := customresources.HasRotateAnnotation(app) 109 | assert.False(t, hasAnnotation) 110 | }) 111 | 112 | t.Run("set annotation should rotate regardless of value", func(t *testing.T) { 113 | app := fixtures.MinimalApplication() 114 | annotations.SetAnnotation(app, annotations.RotateKey, strconv.FormatBool(false)) 115 | 116 | hasAnnotation := customresources.HasRotateAnnotation(app) 117 | assert.True(t, hasAnnotation) 118 | 119 | app = fixtures.MinimalApplication() 120 | annotations.SetAnnotation(app, annotations.RotateKey, strconv.FormatBool(true)) 121 | 122 | hasAnnotation = customresources.HasRotateAnnotation(app) 123 | assert.True(t, hasAnnotation) 124 | }) 125 | } 126 | -------------------------------------------------------------------------------- /pkg/azure/resource/resource.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | naisiov1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 5 | "github.com/nais/msgraph.go/ptr" 6 | msgraph "github.com/nais/msgraph.go/v1.0" 7 | 8 | "github.com/nais/azureator/pkg/azure/permissions" 9 | ) 10 | 11 | // Resource contains metadata that identifies a resource (e.g. User, Groups, Application, or Service Principal) within Azure AD. 12 | type Resource struct { 13 | Name string `json:"name"` 14 | ClientId string `json:"clientId"` 15 | ObjectId string `json:"-"` 16 | PrincipalType PrincipalType `json:"-"` 17 | naisiov1.AccessPolicyInboundRule `json:"-"` 18 | } 19 | 20 | func (r Resource) ToPreAuthorizedApp(actualPermissions permissions.Permissions) msgraph.PreAuthorizedApplication { 21 | clientId := r.ClientId 22 | 23 | desiredPermissions := []string{ 24 | permissions.DefaultPermissionScopeValue, 25 | } 26 | 27 | if r.Permissions != nil { 28 | for _, scope := range r.Permissions.Scopes { 29 | desiredPermissions = append(desiredPermissions, string(scope)) 30 | } 31 | } 32 | 33 | permissionIDs := actualPermissions. 34 | Filter(desiredPermissions...). 35 | PermissionIDs() 36 | 37 | return msgraph.PreAuthorizedApplication{ 38 | AppID: &clientId, 39 | DelegatedPermissionIDs: permissionIDs, 40 | } 41 | } 42 | 43 | func (r Resource) ToAppRoleAssignment(target string, permission permissions.Permission) msgraph.AppRoleAssignment { 44 | return msgraph.AppRoleAssignment{ 45 | AppRoleID: &permission.ID, // The ID of the AppRole belonging to the target resource to be assigned 46 | PrincipalDisplayName: ptr.String(r.Name), // Name of the assignee 47 | PrincipalID: (*msgraph.UUID)(ptr.String(r.ObjectId)), // Service Principal ID for the assignee, i.e. the principal that should be assigned to the app role 48 | PrincipalType: ptr.String(string(r.PrincipalType)), // The Principal type of the assignee, e.g. ServicePrincipal or Group 49 | ResourceID: (*msgraph.UUID)(ptr.String(target)), // Service Principal ID for the target resource, i.e. the application/service principal that owns the app role 50 | } 51 | } 52 | 53 | type Resources []Resource 54 | 55 | func (r Resources) FilterByRole(role permissions.Permission) Resources { 56 | result := make(Resources, 0) 57 | 58 | for _, re := range r { 59 | seen := make(map[naisiov1.AccessPolicyPermission]bool) 60 | 61 | if re.Permissions == nil { 62 | continue 63 | } 64 | 65 | for _, desiredRole := range re.Permissions.Roles { 66 | if string(desiredRole) == role.Name && !seen[desiredRole] { 67 | seen[desiredRole] = true 68 | result = append(result, re) 69 | } 70 | } 71 | } 72 | 73 | return result 74 | } 75 | 76 | func (r Resources) FilterByPrincipalType(principalType PrincipalType) Resources { 77 | result := make(Resources, 0) 78 | 79 | for _, re := range r { 80 | if re.PrincipalType == principalType { 81 | result = append(result, re) 82 | } 83 | } 84 | 85 | return result 86 | } 87 | 88 | func (r Resources) ExtractDesiredAssignees(principalType PrincipalType, role permissions.Permission) Resources { 89 | switch principalType { 90 | case PrincipalTypeGroup: 91 | // ensure that default group role is assigned to all Groups 92 | if role.ID == msgraph.UUID(permissions.DefaultGroupRoleId) { 93 | return r 94 | } 95 | case PrincipalTypeServicePrincipal: 96 | // ensure that default app role is assigned to all ServicePrincipals 97 | if role.Name == permissions.DefaultAppRoleValue { 98 | return r 99 | } 100 | } 101 | 102 | return r.FilterByRole(role) 103 | } 104 | 105 | func (r Resources) Has(other Resource) bool { 106 | for _, existing := range r { 107 | principalTypeMatches := existing.PrincipalType == other.PrincipalType 108 | objectIdMatches := existing.ObjectId == other.ObjectId 109 | 110 | if principalTypeMatches && objectIdMatches { 111 | return true 112 | } 113 | } 114 | 115 | return false 116 | } 117 | 118 | func (r *Resources) Add(resource Resource) { 119 | if !r.Has(resource) { 120 | *r = append(*r, resource) 121 | } 122 | } 123 | 124 | func (r *Resources) AddAll(resources ...Resource) { 125 | for _, resource := range resources { 126 | r.Add(resource) 127 | } 128 | } 129 | 130 | type PrincipalType string 131 | 132 | const ( 133 | PrincipalTypeGroup PrincipalType = "Group" 134 | PrincipalTypeServicePrincipal PrincipalType = "ServicePrincipal" 135 | PrincipalTypeUser PrincipalType = "User" 136 | ) 137 | -------------------------------------------------------------------------------- /pkg/synchronizer/synchronizer.go: -------------------------------------------------------------------------------- 1 | package synchronizer 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/IBM/sarama" 9 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 10 | "github.com/nais/liberator/pkg/kubernetes" 11 | log "github.com/sirupsen/logrus" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | 14 | "github.com/nais/azureator/pkg/annotations" 15 | "github.com/nais/azureator/pkg/event" 16 | "github.com/nais/azureator/pkg/kafka" 17 | ) 18 | 19 | // Synchronizer ensures that the Azure AD applications are resynchronized on relevant events, 20 | // e.g. on creation of previously non-existing pre-authorized applications. 21 | type Synchronizer struct { 22 | clusterName string 23 | client client.Client 24 | reader client.Reader 25 | } 26 | 27 | func New(clusterName string, client client.Client, reader client.Reader) *Synchronizer { 28 | return &Synchronizer{ 29 | clusterName, 30 | client, 31 | reader, 32 | } 33 | } 34 | 35 | // Kafka processes incoming Kafka messages and triggers resynchronization of Azure AD applications as needed. 36 | func (s Synchronizer) Kafka() kafka.Callback { 37 | return func(msg *sarama.ConsumerMessage, logger *log.Entry) (bool, error) { 38 | logger.Debugf("incoming message from Kafka") 39 | 40 | e := &event.Event{} 41 | if err := json.Unmarshal(msg.Value, &e); err != nil { 42 | return false, fmt.Errorf("unmarshalling message to event; ignoring: %w", err) 43 | } 44 | 45 | logger = logger.WithFields(log.Fields{ 46 | "CorrelationID": e.ID, 47 | "application_name": e.Application.Name, 48 | "application_namespace": e.Application.Namespace, 49 | "application_cluster": e.Application.Cluster, 50 | "event_name": e.Name, 51 | }) 52 | 53 | if e.Application.Cluster == s.clusterName { 54 | // events targeting this cluster is handled by [Synchronizer.Local] 55 | logger.Debugf("ignoring kafka event in same cluster '%s'", s.clusterName) 56 | return false, nil 57 | } 58 | 59 | if err := s.process(context.Background(), *e, logger); err != nil { 60 | return true, fmt.Errorf("processing event: %w", err) 61 | } 62 | 63 | return false, nil 64 | } 65 | } 66 | 67 | func (s Synchronizer) Local(ctx context.Context, e event.Event, logger *log.Entry) error { 68 | return s.process(ctx, e, logger) 69 | } 70 | 71 | func (s Synchronizer) process(ctx context.Context, e event.Event, logger *log.Entry) error { 72 | if !e.IsCreated() { 73 | logger.Debugf("ignoring event '%s'", e) 74 | return nil 75 | } 76 | 77 | logger.Infof("processing event '%s' for '%s'...", e, e.Application) 78 | 79 | var apps v1.AzureAdApplicationList 80 | err := s.reader.List(ctx, &apps) 81 | if err != nil { 82 | return fmt.Errorf("fetching AzureAdApplications from cluster: %w", err) 83 | } 84 | 85 | candidateCount := 0 86 | for _, app := range apps.Items { 87 | if hasMatchingPreAuthorizedApp(app, s.clusterName, e) { 88 | candidateID := kubernetes.UniformResourceName(&app, s.clusterName) 89 | candidateCount += 1 90 | 91 | if err := s.resync(ctx, app, e); err != nil { 92 | return fmt.Errorf("resyncing %s: %w", candidateID, err) 93 | } 94 | 95 | logger.Infof("marked '%s' for resync", candidateID) 96 | } 97 | } 98 | 99 | if candidateCount > 0 { 100 | logger.Infof("found and marked %d candidates for resync", candidateCount) 101 | } else { 102 | logger.Infof("no candidates found for resync") 103 | } 104 | return nil 105 | } 106 | 107 | func (s Synchronizer) resync(ctx context.Context, app v1.AzureAdApplication, e event.Event) error { 108 | existing := &v1.AzureAdApplication{} 109 | key := client.ObjectKey{Namespace: app.Namespace, Name: app.Name} 110 | 111 | if err := s.reader.Get(ctx, key, existing); err != nil { 112 | return fmt.Errorf("getting newest version from cluster: %s", err) 113 | } 114 | 115 | annotations.AddToAnnotation(existing, annotations.ResynchronizeKey, e.Application.String()) 116 | annotations.AddToAnnotation(existing, v1.DeploymentCorrelationIDAnnotation, e.ID) 117 | 118 | if err := s.client.Update(ctx, existing); err != nil { 119 | return fmt.Errorf("setting resync annotation: %w", err) 120 | } 121 | 122 | return nil 123 | } 124 | 125 | func hasMatchingPreAuthorizedApp(in v1.AzureAdApplication, clusterName string, e event.Event) bool { 126 | for _, preAuthApp := range in.Spec.PreAuthorizedApplications { 127 | if len(preAuthApp.Namespace) == 0 { 128 | preAuthApp.Namespace = in.GetNamespace() 129 | } 130 | if len(preAuthApp.Cluster) == 0 { 131 | preAuthApp.Cluster = clusterName 132 | } 133 | 134 | nameMatches := preAuthApp.Application == e.Application.Name 135 | namespaceMatches := preAuthApp.Namespace == e.Application.Namespace 136 | clusterMatches := preAuthApp.Cluster == e.Application.Cluster 137 | 138 | if nameMatches && namespaceMatches && clusterMatches { 139 | return true 140 | } 141 | } 142 | 143 | return false 144 | } 145 | -------------------------------------------------------------------------------- /pkg/azure/client/application/permissionscope/permissionscope_test.go: -------------------------------------------------------------------------------- 1 | package permissionscope_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/uuid" 7 | "github.com/nais/msgraph.go/ptr" 8 | msgraph "github.com/nais/msgraph.go/v1.0" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/nais/azureator/pkg/azure/client/application/permissionscope" 12 | "github.com/nais/azureator/pkg/azure/permissions" 13 | "github.com/nais/azureator/pkg/azure/util" 14 | ) 15 | 16 | func TestNew(t *testing.T) { 17 | name := "scope1" 18 | id := msgraph.UUID(uuid.New().String()) 19 | 20 | expected := msgraph.PermissionScope{ 21 | AdminConsentDescription: ptr.String(name), 22 | AdminConsentDisplayName: ptr.String(name), 23 | ID: &id, 24 | IsEnabled: ptr.Bool(true), 25 | Type: ptr.String(permissions.DefaultScopeType), 26 | Value: ptr.String(name), 27 | } 28 | actual := permissionscope.New(id, name) 29 | 30 | assert.Equal(t, expected, actual) 31 | } 32 | 33 | func TestNewGenerateId(t *testing.T) { 34 | name := "scope1" 35 | 36 | actual := permissionscope.NewGenerateId(name) 37 | id := actual.ID 38 | 39 | expected := msgraph.PermissionScope{ 40 | AdminConsentDescription: ptr.String(name), 41 | AdminConsentDisplayName: ptr.String(name), 42 | ID: id, 43 | IsEnabled: ptr.Bool(true), 44 | Type: ptr.String(permissions.DefaultScopeType), 45 | Value: ptr.String(name), 46 | } 47 | 48 | assert.Equal(t, expected, actual) 49 | } 50 | 51 | func TestDefaultScope(t *testing.T) { 52 | id := msgraph.UUID(permissions.DefaultPermissionScopeId) 53 | expected := msgraph.PermissionScope{ 54 | AdminConsentDescription: ptr.String(permissions.DefaultPermissionScopeValue), 55 | AdminConsentDisplayName: ptr.String(permissions.DefaultPermissionScopeValue), 56 | ID: &id, 57 | IsEnabled: ptr.Bool(true), 58 | Type: ptr.String(permissions.DefaultScopeType), 59 | Value: ptr.String(permissions.DefaultPermissionScopeValue), 60 | } 61 | actual := permissionscope.DefaultScope() 62 | 63 | assert.Equal(t, expected, actual) 64 | } 65 | 66 | func TestEnsureScopesRequireAdminConsent(t *testing.T) { 67 | scope1 := permissionscope.NewGenerateId("scope-1") 68 | scope1.Type = ptr.String("User") 69 | scope2 := permissionscope.NewGenerateId("scope-2") 70 | scope2.Type = ptr.String("User") 71 | 72 | scopes := []msgraph.PermissionScope{scope1, scope2} 73 | for _, scope := range scopes { 74 | assert.Equal(t, "User", *scope.Type) 75 | } 76 | 77 | actual := permissionscope.EnsureScopesRequireAdminConsent(scopes) 78 | for _, scope := range actual { 79 | assert.Equal(t, permissions.DefaultScopeType, *scope.Type) 80 | } 81 | } 82 | 83 | func TestEnsureDefaultAppRoleIsEnabled(t *testing.T) { 84 | defaultScope := permissionscope.DefaultScope() 85 | defaultScope.IsEnabled = ptr.Bool(false) 86 | 87 | scopes := []msgraph.PermissionScope{defaultScope} 88 | for _, scope := range scopes { 89 | assert.False(t, *scope.IsEnabled) 90 | } 91 | 92 | actual := permissionscope.EnsureDefaultScopeIsEnabled(scopes) 93 | for _, scope := range actual { 94 | assert.True(t, *scope.IsEnabled) 95 | } 96 | } 97 | 98 | func TestEnsureDefaultScopeIsEnabled(t *testing.T) { 99 | defaultScope := permissionscope.DefaultScope() 100 | defaultScope.IsEnabled = ptr.Bool(false) 101 | 102 | scopes := []msgraph.PermissionScope{defaultScope} 103 | for _, scope := range scopes { 104 | assert.False(t, *scope.IsEnabled) 105 | } 106 | 107 | actual := permissionscope.EnsureDefaultScopeIsEnabled(scopes) 108 | for _, scope := range actual { 109 | assert.True(t, *scope.IsEnabled) 110 | } 111 | } 112 | 113 | func TestFromPermission(t *testing.T) { 114 | permission := permissions.NewGenerateIdEnabled("scope") 115 | scope := permissionscope.FromPermission(permission) 116 | 117 | assert.Equal(t, "scope", *scope.AdminConsentDescription) 118 | assert.Equal(t, "scope", *scope.AdminConsentDisplayName) 119 | assert.Equal(t, "scope", *scope.Value) 120 | assert.Equal(t, permission.ID, *scope.ID) 121 | } 122 | 123 | func TestRemoveDisabled(t *testing.T) { 124 | enabledScope := permissionscope.NewGenerateId("enabled-scope") 125 | enabledScope2 := permissionscope.NewGenerateId("enabled-scope-2") 126 | disabledScope := permissionscope.NewGenerateId("disabled-scope") 127 | disabledScope.IsEnabled = ptr.Bool(false) 128 | 129 | scopes := []msgraph.PermissionScope{ 130 | enabledScope, 131 | enabledScope2, 132 | disabledScope, 133 | } 134 | application := util.EmptyApplication(). 135 | PermissionScopes(scopes). 136 | Build() 137 | 138 | desired := permissionscope.RemoveDisabled(*application) 139 | assert.Len(t, desired, 2) 140 | assert.Contains(t, desired, enabledScope) 141 | assert.Contains(t, desired, enabledScope2) 142 | assert.NotContains(t, desired, disabledScope) 143 | } 144 | -------------------------------------------------------------------------------- /cmd/azurerator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | "github.com/go-logr/zapr" 11 | "github.com/nais/liberator/pkg/tlsutil" 12 | "github.com/sirupsen/logrus" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 15 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 16 | "k8s.io/client-go/tools/leaderelection/resourcelock" 17 | ctrl "sigs.k8s.io/controller-runtime" 18 | "sigs.k8s.io/controller-runtime/pkg/metrics" 19 | metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 20 | 21 | "github.com/nais/azureator/controllers/azureadapplication" 22 | "github.com/nais/azureator/pkg/azure/client" 23 | "github.com/nais/azureator/pkg/config" 24 | "github.com/nais/azureator/pkg/kafka" 25 | "github.com/nais/azureator/pkg/logger" 26 | azureMetrics "github.com/nais/azureator/pkg/metrics" 27 | "github.com/nais/azureator/pkg/synchronizer" 28 | 29 | naisiov1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 30 | // +kubebuilder:scaffold:imports 31 | ) 32 | 33 | var ( 34 | scheme = runtime.NewScheme() 35 | setupLog = ctrl.Log.WithName("setup") 36 | ) 37 | 38 | func init() { 39 | metrics.Registry.MustRegister(azureMetrics.AllMetrics...) 40 | logger.SetupLogrus() 41 | 42 | _ = clientgoscheme.AddToScheme(scheme) 43 | _ = naisiov1.AddToScheme(scheme) 44 | // +kubebuilder:scaffold:scheme 45 | } 46 | 47 | func main() { 48 | err := run() 49 | if err != nil { 50 | log.Fatalf("Run loop errored: %+v", err) 51 | } 52 | 53 | setupLog.Info("Manager shutting down") 54 | } 55 | 56 | func run() error { 57 | ctx, cancel := context.WithCancel(context.Background()) 58 | defer cancel() 59 | 60 | zapLogger, err := logger.ZapLogger() 61 | if err != nil { 62 | return err 63 | } 64 | ctrl.SetLogger(zapr.NewLogger(zapLogger)) 65 | 66 | cfg, err := config.DefaultConfig() 67 | if err != nil { 68 | return err 69 | } 70 | 71 | leaseDuration := 25 * time.Second 72 | renewDeadline := 20 * time.Second 73 | 74 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 75 | Scheme: scheme, 76 | Metrics: metricsserver.Options{ 77 | BindAddress: cfg.MetricsAddr, 78 | }, 79 | LeaderElection: cfg.LeaderElection.Enabled, 80 | LeaderElectionID: fmt.Sprintf("azurerator.nais.io-%s", cfg.Azure.Tenant.Id), 81 | LeaderElectionNamespace: cfg.LeaderElection.Namespace, 82 | LeaderElectionResourceLock: resourcelock.LeasesResourceLock, 83 | LeaseDuration: &leaseDuration, 84 | RenewDeadline: &renewDeadline, 85 | }) 86 | if err != nil { 87 | return fmt.Errorf("unable to start manager: %w", err) 88 | } 89 | 90 | azureClient, err := client.New(ctx, &cfg.Azure) 91 | if err != nil { 92 | return fmt.Errorf("instantiating Azure client: %w", err) 93 | } 94 | 95 | azureCtx, cancel := context.WithTimeout(ctx, 1*time.Minute) 96 | defer cancel() 97 | 98 | azureOpenIDConfig, err := config.NewAzureOpenIdConfig(azureCtx, cfg.Azure.Tenant) 99 | if err != nil { 100 | return fmt.Errorf("fetching Azure OpenID Configuration: %w", err) 101 | } 102 | 103 | syncer := synchronizer.New(cfg.ClusterName, mgr.GetClient(), mgr.GetAPIReader()) 104 | 105 | var kafkaProducer *kafka.Producer 106 | if cfg.Kafka.Enabled { 107 | kafkaLogger := logrus.StandardLogger() 108 | var tlsConfig *tls.Config 109 | 110 | if cfg.Kafka.TLS.Enabled { 111 | tlsConfig, err = tlsutil.TLSConfigFromFiles(cfg.Kafka.TLS.CertificatePath, cfg.Kafka.TLS.PrivateKeyPath, cfg.Kafka.TLS.CAPath) 112 | if err != nil { 113 | return fmt.Errorf("loading Kafka TLS credentials: %w", err) 114 | } 115 | } 116 | 117 | kafkaProducer, err = kafka.NewProducer(*cfg, tlsConfig, kafkaLogger) 118 | if err != nil { 119 | return fmt.Errorf("setting up kafka producer: %w", err) 120 | } 121 | 122 | _, err = kafka.NewConsumer(ctx, *cfg, tlsConfig, kafkaLogger, syncer.Kafka()) 123 | if err != nil { 124 | return fmt.Errorf("setting up kafka consumer: %w", err) 125 | } 126 | } 127 | 128 | if err = (&azureadapplication.Reconciler{ 129 | Client: mgr.GetClient(), 130 | Reader: mgr.GetAPIReader(), 131 | Scheme: mgr.GetScheme(), 132 | AzureClient: azureClient, 133 | Config: cfg, 134 | Recorder: mgr.GetEventRecorderFor("azurerator"), 135 | AzureOpenIDConfig: *azureOpenIDConfig, 136 | KafkaProducer: kafkaProducer, 137 | Synchronizer: syncer, 138 | }).SetupWithManager(mgr); err != nil { 139 | return fmt.Errorf("unable to create controller: %w", err) 140 | } 141 | 142 | // +kubebuilder:scaffold:builder 143 | 144 | setupLog.Info("starting metrics refresh goroutine") 145 | clusterMetrics := azureMetrics.New(mgr.GetClient()) 146 | go clusterMetrics.Refresh(ctx) 147 | 148 | setupLog.Info("starting manager") 149 | if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { 150 | return fmt.Errorf("problem running manager: %w", err) 151 | } 152 | 153 | return nil 154 | } 155 | -------------------------------------------------------------------------------- /pkg/azure/client/application/redirecturi/redirecturi_test.go: -------------------------------------------------------------------------------- 1 | package redirecturi_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/nais/azureator/pkg/azure/client/application/redirecturi" 8 | 9 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 10 | "github.com/nais/msgraph.go/ptr" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestRedirectUriApp(t *testing.T) { 15 | t.Run("web application, default", func(t *testing.T) { 16 | app := azureAdApp() 17 | a := redirecturi.App(app) 18 | expected := ` 19 | { 20 | "web": { 21 | "redirectUris": [ 22 | "https://test.host/callback" 23 | ] 24 | }, 25 | "spa": { 26 | "redirectUris": [] 27 | } 28 | } 29 | ` 30 | assertJson(t, a, expected) 31 | }) 32 | 33 | t.Run("web application, empty urls", func(t *testing.T) { 34 | app := azureAdApp() 35 | app.Spec.ReplyUrls = make([]v1.AzureAdReplyUrl, 0) 36 | 37 | a := redirecturi.App(app) 38 | expected := ` 39 | { 40 | "web": { 41 | "redirectUris": [] 42 | }, 43 | "spa": { 44 | "redirectUris": [] 45 | } 46 | } 47 | ` 48 | assertJson(t, a, expected) 49 | }) 50 | 51 | t.Run("single-page application", func(t *testing.T) { 52 | app := azureAdApp() 53 | app.Spec.SinglePageApplication = ptr.Bool(true) 54 | 55 | a := redirecturi.App(app) 56 | expected := ` 57 | { 58 | "web": { 59 | "redirectUris": [] 60 | }, 61 | "spa": { 62 | "redirectUris": [ 63 | "https://test.host/callback" 64 | ] 65 | } 66 | } 67 | ` 68 | assertJson(t, a, expected) 69 | }) 70 | 71 | t.Run("single-page application, empty urls", func(t *testing.T) { 72 | app := azureAdApp() 73 | app.Spec.SinglePageApplication = ptr.Bool(true) 74 | app.Spec.ReplyUrls = make([]v1.AzureAdReplyUrl, 0) 75 | 76 | a := redirecturi.App(app) 77 | expected := ` 78 | { 79 | "web": { 80 | "redirectUris": [] 81 | }, 82 | "spa": { 83 | "redirectUris": [] 84 | } 85 | } 86 | ` 87 | assertJson(t, a, expected) 88 | }) 89 | } 90 | 91 | func TestGetReplyUrlsStringSlice(t *testing.T) { 92 | t.Run("Empty Application should return empty slice of reply URLs", func(t *testing.T) { 93 | p := &v1.AzureAdApplication{} 94 | actual := redirecturi.ReplyUrlsToStringSlice(p) 95 | assert.Empty(t, actual) 96 | }) 97 | 98 | t.Run("Application with reply URL should return equivalent string slice of reply URLs", func(t *testing.T) { 99 | url := "https://test.host/callback" 100 | p := &v1.AzureAdApplication{Spec: v1.AzureAdApplicationSpec{ReplyUrls: []v1.AzureAdReplyUrl{{Url: v1.AzureAdReplyUrlString(url)}}}} 101 | actual := redirecturi.ReplyUrlsToStringSlice(p) 102 | assert.NotEmpty(t, actual) 103 | assert.Len(t, actual, 1) 104 | assert.Contains(t, actual, url) 105 | }) 106 | 107 | t.Run("Application with duplicate reply URLs should return set of reply URLs", func(t *testing.T) { 108 | p := &v1.AzureAdApplication{Spec: v1.AzureAdApplicationSpec{ 109 | ReplyUrls: []v1.AzureAdReplyUrl{ 110 | {Url: "https://test.host/callback"}, 111 | {Url: "https://test.host/callback"}, 112 | {Url: "https://test.host/other-callback"}, 113 | {Url: "https://test.host/other-callback"}, 114 | }, 115 | }} 116 | actual := redirecturi.ReplyUrlsToStringSlice(p) 117 | assert.NotEmpty(t, actual) 118 | assert.Len(t, actual, 2) 119 | assert.ElementsMatch(t, actual, []string{"https://test.host/callback", "https://test.host/other-callback"}) 120 | }) 121 | 122 | t.Run("Application with invalid URLs should return only valid URLs", func(t *testing.T) { 123 | p := &v1.AzureAdApplication{Spec: v1.AzureAdApplicationSpec{ 124 | ReplyUrls: []v1.AzureAdReplyUrl{ 125 | {Url: "https://test.host/callback"}, 126 | {Url: "https://test.host/oauth2/callback"}, 127 | {Url: "http://localhost/oauth2/callback"}, 128 | {Url: "http://localhost:8080/oauth2/callback"}, 129 | {Url: "http://127.0.0.1/oauth2/callback"}, 130 | {Url: "http://127.0.0.1:8080/oauth2/callback"}, 131 | {Url: "https://https://test.host/callback"}, 132 | {Url: `https://test."host/other-callback"`}, 133 | }, 134 | }} 135 | actual := redirecturi.ReplyUrlsToStringSlice(p) 136 | assert.NotEmpty(t, actual) 137 | assert.Len(t, actual, 6) 138 | assert.ElementsMatch(t, actual, []string{ 139 | "https://test.host/callback", 140 | "https://test.host/oauth2/callback", 141 | "http://localhost/oauth2/callback", 142 | "http://localhost:8080/oauth2/callback", 143 | "http://127.0.0.1/oauth2/callback", 144 | "http://127.0.0.1:8080/oauth2/callback", 145 | }) 146 | }) 147 | } 148 | 149 | func assertJson(t *testing.T, input any, expected string) { 150 | j, _ := json.Marshal(input) 151 | assert.JSONEq(t, expected, string(j)) 152 | } 153 | 154 | func azureAdApp() *v1.AzureAdApplication { 155 | url := "https://test.host/callback" 156 | return &v1.AzureAdApplication{ 157 | Spec: v1.AzureAdApplicationSpec{ 158 | ReplyUrls: []v1.AzureAdReplyUrl{ 159 | { 160 | Url: v1.AzureAdReplyUrlString(url), 161 | }, 162 | }, 163 | }, 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /pkg/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" 9 | "github.com/nais/liberator/pkg/kubernetes" 10 | "github.com/prometheus/client_golang/prometheus" 11 | log "github.com/sirupsen/logrus" 12 | corev1 "k8s.io/api/core/v1" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | 15 | "github.com/nais/azureator/pkg/labels" 16 | "github.com/nais/azureator/pkg/retry" 17 | ) 18 | 19 | const ( 20 | labelNamespace = "namespace" 21 | ) 22 | 23 | var ( 24 | AzureAppsTotal = prometheus.NewGauge( 25 | prometheus.GaugeOpts{ 26 | Name: "azureadapp_total", 27 | }) 28 | AzureAppSecretsTotal = prometheus.NewGauge( 29 | prometheus.GaugeOpts{ 30 | Name: "azureadapp_secrets_total", 31 | Help: "Total number of azureadapp secrets", 32 | }, 33 | ) 34 | AzureAppOrphanedTotal = prometheus.NewCounterVec( 35 | prometheus.CounterOpts{ 36 | Name: "azureadapp_orphaned_total", 37 | Help: "Number of orphaned azuread apps (exists in Azure AD without matching k8s resource)", 38 | }, 39 | []string{labelNamespace}, 40 | ) 41 | AzureAppsCreatedCount = prometheus.NewCounterVec( 42 | prometheus.CounterOpts{ 43 | Name: "azureadapp_created_count", 44 | Help: "Number of azureadapps created successfully", 45 | }, 46 | []string{labelNamespace}, 47 | ) 48 | AzureAppsUpdatedCount = prometheus.NewCounterVec( 49 | prometheus.CounterOpts{ 50 | Name: "azureadapp_updated_count", 51 | Help: "Number of azureadapps updated successfully", 52 | }, 53 | []string{labelNamespace}, 54 | ) 55 | AzureAppsRotatedCount = prometheus.NewCounterVec( 56 | prometheus.CounterOpts{ 57 | Name: "azureadapp_rotated_count", 58 | Help: "Number of azureadapps successfully rotated credentials", 59 | }, 60 | []string{labelNamespace}, 61 | ) 62 | AzureAppsProcessedCount = prometheus.NewCounterVec( 63 | prometheus.CounterOpts{ 64 | Name: "azureadapp_processed_count", 65 | Help: "Number of azureadapps processed successfully", 66 | }, 67 | []string{labelNamespace}, 68 | ) 69 | AzureAppsFailedProcessingCount = prometheus.NewCounterVec( 70 | prometheus.CounterOpts{ 71 | Name: "azureadapp_failed_processing_count", 72 | Help: "Number of azureadapps that failed processing", 73 | }, 74 | []string{labelNamespace}, 75 | ) 76 | AzureAppsDeletedCount = prometheus.NewCounterVec( 77 | prometheus.CounterOpts{ 78 | Name: "azureadapp_deleted_count", 79 | Help: "Number of azureadapps successfully deleted", 80 | }, 81 | []string{labelNamespace}, 82 | ) 83 | AzureAppsSkippedCount = prometheus.NewCounterVec( 84 | prometheus.CounterOpts{ 85 | Name: "azureadapp_skipped_count", 86 | Help: "Number of azureapps skipped due to certain conditions", 87 | }, 88 | []string{labelNamespace}, 89 | ) 90 | ) 91 | 92 | var AllMetrics = []prometheus.Collector{ 93 | AzureAppsTotal, 94 | AzureAppSecretsTotal, 95 | AzureAppOrphanedTotal, 96 | AzureAppsProcessedCount, 97 | AzureAppsFailedProcessingCount, 98 | AzureAppsCreatedCount, 99 | AzureAppsUpdatedCount, 100 | AzureAppsRotatedCount, 101 | AzureAppsDeletedCount, 102 | AzureAppsSkippedCount, 103 | } 104 | 105 | var AllCounters = []*prometheus.CounterVec{ 106 | AzureAppsProcessedCount, 107 | AzureAppsFailedProcessingCount, 108 | AzureAppsCreatedCount, 109 | AzureAppsUpdatedCount, 110 | AzureAppsRotatedCount, 111 | AzureAppsDeletedCount, 112 | AzureAppsSkippedCount, 113 | } 114 | 115 | func IncWithNamespaceLabel(metric *prometheus.CounterVec, namespace string) { 116 | metric.WithLabelValues(namespace).Inc() 117 | } 118 | 119 | type Metrics interface { 120 | Refresh(ctx context.Context) 121 | } 122 | 123 | type metrics struct { 124 | reader client.Reader 125 | } 126 | 127 | func New(reader client.Reader) Metrics { 128 | return metrics{ 129 | reader: reader, 130 | } 131 | } 132 | 133 | func (m metrics) InitWithNamespaceLabels() { 134 | var ns corev1.NamespaceList 135 | var err error 136 | 137 | retryable := func(ctx context.Context) error { 138 | ns, err = kubernetes.ListNamespaces(context.Background(), m.reader) 139 | if err != nil { 140 | return retry.RetryableError(fmt.Errorf("listing namespaces: %w", err)) 141 | } 142 | return nil 143 | } 144 | 145 | err = retry.Fibonacci(1*time.Second). 146 | WithMaxDuration(1*time.Minute). 147 | Do(context.Background(), retryable) 148 | if err != nil { 149 | log.Error(err) 150 | } 151 | 152 | for _, n := range ns.Items { 153 | for _, c := range AllCounters { 154 | c.WithLabelValues(n.Name).Add(0) 155 | } 156 | } 157 | 158 | log.Infof("metrics with namespace labels initialized") 159 | } 160 | 161 | func (m metrics) Refresh(ctx context.Context) { 162 | var err error 163 | exp := 10 * time.Second 164 | 165 | mLabels := client.MatchingLabels{ 166 | labels.TypeLabelKey: labels.TypeLabelValue, 167 | } 168 | 169 | var secretList corev1.SecretList 170 | var azureAdAppList v1.AzureAdApplicationList 171 | 172 | m.InitWithNamespaceLabels() 173 | 174 | t := time.NewTicker(exp) 175 | for range t.C { 176 | if err = m.reader.List(ctx, &secretList, mLabels); err != nil { 177 | log.Errorf("failed to list secrets: %v", err) 178 | } 179 | AzureAppSecretsTotal.Set(float64(len(secretList.Items))) 180 | 181 | if err = m.reader.List(ctx, &azureAdAppList); err != nil { 182 | log.Errorf("failed to list azure apps: %v", err) 183 | } 184 | AzureAppsTotal.Set(float64(len(azureAdAppList.Items))) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /pkg/secrets/secrets.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/nais/azureator/pkg/azure/credentials" 9 | "github.com/nais/azureator/pkg/azure/result" 10 | "github.com/nais/azureator/pkg/config" 11 | ) 12 | 13 | const ( 14 | DefaultKeyPrefix = "AZURE" 15 | 16 | certificateIdSuffix = "_APP_CERTIFICATE_KEY_ID" 17 | clientSecretSuffix = "_APP_CLIENT_SECRET" 18 | jwkSuffix = "_APP_JWK" 19 | jwksSuffix = "_APP_JWKS" 20 | passwordIdSuffix = "_APP_PASSWORD_KEY_ID" 21 | 22 | clientIdSuffix = "_APP_CLIENT_ID" 23 | preAuthAppsSuffix = "_APP_PRE_AUTHORIZED_APPS" 24 | tenantIdSuffix = "_APP_TENANT_ID" 25 | wellKnownUrlSuffix = "_APP_WELL_KNOWN_URL" 26 | 27 | nextCertificateIdSuffix = "_APP_NEXT_CERTIFICATE_KEY_ID" 28 | nextClientSecretSuffix = "_APP_NEXT_CLIENT_SECRET" 29 | nextJwkSuffix = "_APP_NEXT_JWK" 30 | nextPasswordIdSuffix = "_APP_NEXT_PASSWORD_KEY_ID" 31 | 32 | openIDConfigIssuerKey = "_OPENID_CONFIG_ISSUER" 33 | openIDConfigJwksUriKey = "_OPENID_CONFIG_JWKS_URI" 34 | openIDConfigTokenEndpointKey = "_OPENID_CONFIG_TOKEN_ENDPOINT" 35 | ) 36 | 37 | type SecretDataKeys struct { 38 | ClientId string 39 | CurrentCredentials CredentialKeys 40 | NextCredentials CredentialKeys 41 | PreAuthApps string 42 | TenantId string 43 | WellKnownUrl string 44 | OpenId OpenIdConfigKeys 45 | } 46 | 47 | func NewSecretDataKeys(keyPrefix ...string) SecretDataKeys { 48 | var prefix string 49 | 50 | if len(keyPrefix) == 0 { 51 | prefix = DefaultKeyPrefix 52 | } else { 53 | prefix = secretPrefix(keyPrefix[0]) 54 | } 55 | 56 | return SecretDataKeys{ 57 | ClientId: prefix + clientIdSuffix, 58 | CurrentCredentials: CredentialKeys{ 59 | CertificateKeyId: prefix + certificateIdSuffix, 60 | ClientSecret: prefix + clientSecretSuffix, 61 | PasswordKeyId: prefix + passwordIdSuffix, 62 | Jwks: prefix + jwksSuffix, 63 | Jwk: prefix + jwkSuffix, 64 | }, 65 | NextCredentials: CredentialKeys{ 66 | CertificateKeyId: prefix + nextCertificateIdSuffix, 67 | ClientSecret: prefix + nextClientSecretSuffix, 68 | PasswordKeyId: prefix + nextPasswordIdSuffix, 69 | Jwk: prefix + nextJwkSuffix, 70 | }, 71 | PreAuthApps: prefix + preAuthAppsSuffix, 72 | TenantId: prefix + tenantIdSuffix, 73 | WellKnownUrl: prefix + wellKnownUrlSuffix, 74 | OpenId: OpenIdConfigKeys{ 75 | Issuer: prefix + openIDConfigIssuerKey, 76 | JwksUri: prefix + openIDConfigJwksUriKey, 77 | TokenEndpoint: prefix + openIDConfigTokenEndpointKey, 78 | }, 79 | } 80 | } 81 | 82 | func (s SecretDataKeys) AllKeys() []string { 83 | return []string{ 84 | s.ClientId, 85 | s.CurrentCredentials.CertificateKeyId, 86 | s.CurrentCredentials.ClientSecret, 87 | s.CurrentCredentials.PasswordKeyId, 88 | s.CurrentCredentials.Jwks, 89 | s.CurrentCredentials.Jwk, 90 | s.NextCredentials.CertificateKeyId, 91 | s.NextCredentials.ClientSecret, 92 | s.NextCredentials.PasswordKeyId, 93 | s.NextCredentials.Jwk, 94 | s.PreAuthApps, 95 | s.TenantId, 96 | s.WellKnownUrl, 97 | s.OpenId.Issuer, 98 | s.OpenId.JwksUri, 99 | s.OpenId.TokenEndpoint, 100 | } 101 | } 102 | 103 | func secretPrefix(prefix string) string { 104 | if len(prefix) > 0 { 105 | return strings.ToUpper(strings.TrimSuffix(prefix, "_")) 106 | } 107 | return DefaultKeyPrefix 108 | } 109 | 110 | type CredentialKeys struct { 111 | CertificateKeyId string 112 | ClientSecret string 113 | PasswordKeyId string 114 | Jwks string 115 | Jwk string 116 | } 117 | 118 | type OpenIdConfigKeys struct { 119 | Issuer string 120 | JwksUri string 121 | TokenEndpoint string 122 | } 123 | 124 | func SecretData(app result.Application, set credentials.Set, azureOpenIDConfig config.AzureOpenIdConfig, keys SecretDataKeys) (map[string]string, error) { 125 | jwkJson, err := json.Marshal(set.Current.Certificate.Jwk.Private) 126 | if err != nil { 127 | return nil, fmt.Errorf("marshalling private JWK: %w", err) 128 | } 129 | 130 | nextJwkJson, err := json.Marshal(set.Next.Certificate.Jwk.Private) 131 | if err != nil { 132 | return nil, fmt.Errorf("marshalling next private JWK: %w", err) 133 | } 134 | 135 | jwksJson, err := json.Marshal(set.Current.Certificate.Jwk.ToPrivateJwks()) 136 | if err != nil { 137 | return nil, fmt.Errorf("marshalling private JWKS: %w", err) 138 | } 139 | 140 | preAuthAppsJson, err := json.Marshal(app.PreAuthorizedApps.Valid) 141 | if err != nil { 142 | return nil, fmt.Errorf("marshalling preauthorized apps: %w", err) 143 | } 144 | 145 | return map[string]string{ 146 | keys.ClientId: app.ClientId, 147 | keys.CurrentCredentials.CertificateKeyId: set.Current.Certificate.KeyId, 148 | keys.CurrentCredentials.ClientSecret: set.Current.Password.ClientSecret, 149 | keys.CurrentCredentials.Jwks: string(jwksJson), 150 | keys.CurrentCredentials.Jwk: string(jwkJson), 151 | keys.CurrentCredentials.PasswordKeyId: set.Current.Password.KeyId, 152 | keys.NextCredentials.ClientSecret: set.Next.Password.ClientSecret, 153 | keys.NextCredentials.CertificateKeyId: set.Next.Certificate.KeyId, 154 | keys.NextCredentials.Jwk: string(nextJwkJson), 155 | keys.NextCredentials.PasswordKeyId: set.Next.Password.KeyId, 156 | keys.PreAuthApps: string(preAuthAppsJson), 157 | keys.TenantId: app.Tenant, 158 | keys.WellKnownUrl: azureOpenIDConfig.WellKnownEndpoint, 159 | keys.OpenId.Issuer: azureOpenIDConfig.Issuer, 160 | keys.OpenId.JwksUri: azureOpenIDConfig.JwksURI, 161 | keys.OpenId.TokenEndpoint: azureOpenIDConfig.TokenEndpoint, 162 | }, nil 163 | } 164 | -------------------------------------------------------------------------------- /pkg/azure/client/credentials.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/nais/azureator/pkg/azure" 8 | "github.com/nais/azureator/pkg/azure/client/keycredential" 9 | "github.com/nais/azureator/pkg/azure/client/passwordcredential" 10 | "github.com/nais/azureator/pkg/azure/credentials" 11 | "github.com/nais/azureator/pkg/transaction" 12 | ) 13 | 14 | type credentialsClient struct { 15 | Client 16 | } 17 | 18 | func (c credentialsClient) KeyCredential() keycredential.KeyCredential { 19 | return keycredential.NewKeyCredential(c) 20 | } 21 | 22 | func (c credentialsClient) PasswordCredential() passwordcredential.PasswordCredential { 23 | return passwordcredential.NewPasswordCredential(c) 24 | } 25 | 26 | // Add adds credentials for an existing AAD application 27 | func (c credentialsClient) Add(tx transaction.Transaction) (credentials.Set, error) { 28 | // sleep to prevent concurrent modification error from Microsoft 29 | time.Sleep(c.DelayIntervalBetweenModifications()) 30 | 31 | currPasswordCredential, err := c.PasswordCredential().Add(tx) 32 | if err != nil { 33 | return credentials.Set{}, fmt.Errorf("adding current password credential: %w", err) 34 | } 35 | 36 | time.Sleep(c.DelayIntervalBetweenModifications()) 37 | 38 | nextPasswordCredential, err := c.PasswordCredential().Add(tx) 39 | if err != nil { 40 | return credentials.Set{}, fmt.Errorf("adding next password credential: %w", err) 41 | } 42 | 43 | time.Sleep(c.DelayIntervalBetweenModifications()) 44 | 45 | keyCredentialSet, err := c.KeyCredential().Add(tx) 46 | if err != nil { 47 | return credentials.Set{}, fmt.Errorf("adding key credential set: %w", err) 48 | } 49 | 50 | return credentials.Set{ 51 | Current: credentials.Credentials{ 52 | Certificate: credentials.Certificate{ 53 | KeyId: string(*keyCredentialSet.Current.KeyCredential.KeyID), 54 | Jwk: keyCredentialSet.Current.Jwk, 55 | }, 56 | Password: credentials.Password{ 57 | KeyId: string(*currPasswordCredential.KeyID), 58 | ClientSecret: *currPasswordCredential.SecretText, 59 | }, 60 | }, 61 | Next: credentials.Credentials{ 62 | Certificate: credentials.Certificate{ 63 | KeyId: string(*keyCredentialSet.Next.KeyCredential.KeyID), 64 | Jwk: keyCredentialSet.Next.Jwk, 65 | }, 66 | Password: credentials.Password{ 67 | KeyId: string(*nextPasswordCredential.KeyID), 68 | ClientSecret: *nextPasswordCredential.SecretText, 69 | }, 70 | }, 71 | }, nil 72 | } 73 | 74 | // DeleteExpired deletes all expired credentials for the application in Azure AD. 75 | func (c credentialsClient) DeleteExpired(tx transaction.Transaction) error { 76 | err := c.KeyCredential().DeleteExpired(tx) 77 | if err != nil { 78 | return fmt.Errorf("deleting expired key credentials: %w", err) 79 | } 80 | 81 | err = c.PasswordCredential().DeleteExpired(tx) 82 | if err != nil { 83 | return fmt.Errorf("deleting expired password credentials: %w", err) 84 | } 85 | 86 | return nil 87 | } 88 | 89 | // DeleteUnused deletes unused credentials for an existing AAD application. 90 | func (c credentialsClient) DeleteUnused(tx transaction.Transaction) error { 91 | err := c.KeyCredential().DeleteUnused(tx) 92 | if err != nil { 93 | return fmt.Errorf("deleting unused key credentials: %w", err) 94 | } 95 | 96 | err = c.PasswordCredential().DeleteUnused(tx) 97 | if err != nil { 98 | return fmt.Errorf("deleting unused password credentials: %w", err) 99 | } 100 | 101 | return nil 102 | } 103 | 104 | // Purge removes all credentials for the application in Azure AD. 105 | func (c credentialsClient) Purge(tx transaction.Transaction) error { 106 | err := c.PasswordCredential().Purge(tx) 107 | if err != nil { 108 | return fmt.Errorf("purging password credentials: %w", err) 109 | } 110 | 111 | err = c.KeyCredential().Purge(tx) 112 | if err != nil { 113 | return fmt.Errorf("purging key credentials: %w", err) 114 | } 115 | 116 | return nil 117 | } 118 | 119 | // Rotate rotates credentials for an existing AAD application 120 | func (c credentialsClient) Rotate(tx transaction.Transaction) (credentials.Set, error) { 121 | time.Sleep(c.DelayIntervalBetweenModifications()) // sleep to prevent concurrent modification error from Microsoft 122 | 123 | nextPasswordCredential, err := c.PasswordCredential().Rotate(tx) 124 | if err != nil { 125 | return credentials.Set{}, fmt.Errorf("rotating password credential: %w", err) 126 | } 127 | 128 | time.Sleep(c.DelayIntervalBetweenModifications()) 129 | 130 | nextKeyCredential, nextJwk, err := c.KeyCredential().Rotate(tx) 131 | if err != nil { 132 | return credentials.Set{}, fmt.Errorf("rotating key credential: %w", err) 133 | } 134 | 135 | return credentials.Set{ 136 | Current: tx.Secrets.LatestCredentials.Set.Next, 137 | Next: credentials.Credentials{ 138 | Certificate: credentials.Certificate{ 139 | KeyId: string(*nextKeyCredential.KeyID), 140 | Jwk: *nextJwk, 141 | }, 142 | Password: credentials.Password{ 143 | KeyId: string(*nextPasswordCredential.KeyID), 144 | ClientSecret: *nextPasswordCredential.SecretText, 145 | }, 146 | }, 147 | }, nil 148 | } 149 | 150 | // Validate validates the given credentials set against the actual state for the application in Azure AD. 151 | func (c credentialsClient) Validate(tx transaction.Transaction, existing credentials.Set) (bool, error) { 152 | validPasswordCredentials, err := c.PasswordCredential().Validate(tx, existing) 153 | if err != nil { 154 | return false, fmt.Errorf("validating password credentials: %w", err) 155 | } 156 | 157 | validateKeyCredentials, err := c.KeyCredential().Validate(tx, existing) 158 | if err != nil { 159 | return false, fmt.Errorf("validating key credentials: %w", err) 160 | } 161 | 162 | return validPasswordCredentials && validateKeyCredentials, nil 163 | } 164 | 165 | func NewCredentials(client Client) azure.Credentials { 166 | return credentialsClient{Client: client} 167 | } 168 | -------------------------------------------------------------------------------- /pkg/annotations/annotations_test.go: -------------------------------------------------------------------------------- 1 | package annotations_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/nais/azureator/pkg/annotations" 9 | "github.com/nais/azureator/pkg/fixtures" 10 | ) 11 | 12 | func TestAddToAnnotation(t *testing.T) { 13 | newValue := "new-value" 14 | 15 | t.Run("annotation exists", func(t *testing.T) { 16 | for _, tt := range []struct { 17 | name string 18 | existing string 19 | expected string 20 | }{ 21 | { 22 | name: "one value", 23 | existing: "some-value", 24 | expected: "some-value,new-value", 25 | }, 26 | { 27 | name: "two values", 28 | existing: "some-value,some-other-value", 29 | expected: "some-value,some-other-value,new-value", 30 | }, 31 | { 32 | name: "three values", 33 | existing: "some-value,some-other-value,another-value", 34 | expected: "some-value,some-other-value,another-value,new-value", 35 | }, 36 | } { 37 | t.Run(tt.name, func(t *testing.T) { 38 | app := fixtures.MinimalApplication() 39 | 40 | annotations.SetAnnotation(app, "some-key", tt.existing) 41 | val, ok := annotations.HasAnnotation(app, "some-key") 42 | assert.True(t, ok) 43 | assert.Equal(t, tt.existing, val) 44 | 45 | annotations.AddToAnnotation(app, "some-key", newValue) 46 | 47 | val, ok = annotations.HasAnnotation(app, "some-key") 48 | assert.True(t, ok) 49 | assert.Equal(t, tt.expected, val) 50 | }) 51 | } 52 | }) 53 | 54 | t.Run("no matching annotation", func(t *testing.T) { 55 | app := fixtures.MinimalApplication() 56 | val, ok := annotations.HasAnnotation(app, "some-key") 57 | assert.False(t, ok) 58 | assert.Empty(t, val) 59 | 60 | annotations.AddToAnnotation(app, "some-key", newValue) 61 | 62 | val, ok = annotations.HasAnnotation(app, "some-key") 63 | assert.True(t, ok) 64 | assert.Equal(t, "new-value", val) 65 | }) 66 | } 67 | 68 | func TestHasAnnotation(t *testing.T) { 69 | t.Run("annotation exists", func(t *testing.T) { 70 | app := fixtures.MinimalApplication() 71 | ann := make(map[string]string) 72 | ann["some-key"] = "some-value" 73 | app.SetAnnotations(ann) 74 | 75 | val, ok := annotations.HasAnnotation(app, "some-key") 76 | assert.True(t, ok) 77 | assert.Equal(t, "some-value", val) 78 | }) 79 | 80 | t.Run("no matching annotation", func(t *testing.T) { 81 | app := fixtures.MinimalApplication() 82 | 83 | val, ok := annotations.HasAnnotation(app, "some-key") 84 | assert.False(t, ok) 85 | assert.Empty(t, val) 86 | }) 87 | } 88 | 89 | func TestRemoveAnnotation(t *testing.T) { 90 | t.Run("annotation exists", func(t *testing.T) { 91 | app := fixtures.MinimalApplication() 92 | annotations.SetAnnotation(app, "some-key", "some-value") 93 | val, ok := annotations.HasAnnotation(app, "some-key") 94 | assert.True(t, ok) 95 | assert.Equal(t, "some-value", val) 96 | 97 | annotations.RemoveAnnotation(app, "some-key") 98 | 99 | val, ok = annotations.HasAnnotation(app, "some-key") 100 | assert.False(t, ok) 101 | assert.Empty(t, val) 102 | }) 103 | 104 | t.Run("no matching annotation", func(t *testing.T) { 105 | app := fixtures.MinimalApplication() 106 | val, ok := annotations.HasAnnotation(app, "some-key") 107 | assert.False(t, ok) 108 | assert.Empty(t, val) 109 | 110 | annotations.RemoveAnnotation(app, "some-key") 111 | 112 | val, ok = annotations.HasAnnotation(app, "some-key") 113 | assert.False(t, ok) 114 | assert.Empty(t, val) 115 | }) 116 | } 117 | 118 | func TestRemoveFromAnnotation(t *testing.T) { 119 | t.Run("annotation exists, with 1 value", func(t *testing.T) { 120 | app := fixtures.MinimalApplication() 121 | annotations.SetAnnotation(app, "some-key", "some-value") 122 | val, ok := annotations.HasAnnotation(app, "some-key") 123 | assert.True(t, ok) 124 | assert.Equal(t, "some-value", val) 125 | 126 | annotations.RemoveFromAnnotation(app, "some-key") 127 | 128 | val, ok = annotations.HasAnnotation(app, "some-key") 129 | assert.False(t, ok) 130 | assert.Empty(t, val) 131 | }) 132 | 133 | t.Run("annotation exists, with multiple values", func(t *testing.T) { 134 | for _, tt := range []struct { 135 | name string 136 | value string 137 | expected string 138 | }{ 139 | { 140 | name: "two values", 141 | value: "some-value,some-other-value", 142 | expected: "some-other-value", 143 | }, 144 | { 145 | name: "three values", 146 | value: "some-value,some-other-value,another-value", 147 | expected: "some-other-value,another-value", 148 | }, 149 | } { 150 | t.Run(tt.name, func(t *testing.T) { 151 | app := fixtures.MinimalApplication() 152 | 153 | annotations.SetAnnotation(app, "some-key", tt.value) 154 | val, ok := annotations.HasAnnotation(app, "some-key") 155 | assert.True(t, ok) 156 | assert.Equal(t, tt.value, val) 157 | 158 | annotations.RemoveFromAnnotation(app, "some-key") 159 | 160 | val, ok = annotations.HasAnnotation(app, "some-key") 161 | assert.True(t, ok) 162 | assert.Equal(t, tt.expected, val) 163 | }) 164 | } 165 | }) 166 | 167 | t.Run("no matching annotation", func(t *testing.T) { 168 | app := fixtures.MinimalApplication() 169 | val, ok := annotations.HasAnnotation(app, "some-key") 170 | assert.False(t, ok) 171 | assert.Empty(t, val) 172 | 173 | annotations.RemoveFromAnnotation(app, "some-key") 174 | 175 | val, ok = annotations.HasAnnotation(app, "some-key") 176 | assert.False(t, ok) 177 | assert.Empty(t, val) 178 | }) 179 | } 180 | 181 | func TestSetAnnotation(t *testing.T) { 182 | app := fixtures.MinimalApplication() 183 | 184 | val, ok := annotations.HasAnnotation(app, "some-key") 185 | assert.False(t, ok) 186 | assert.Empty(t, val) 187 | 188 | annotations.SetAnnotation(app, "some-key", "some-value") 189 | 190 | val, ok = annotations.HasAnnotation(app, "some-key") 191 | assert.True(t, ok) 192 | assert.Equal(t, "some-value", val) 193 | } 194 | -------------------------------------------------------------------------------- /pkg/azure/client/application/approle/approlemap_test.go: -------------------------------------------------------------------------------- 1 | package approle_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nais/msgraph.go/ptr" 7 | msgraph "github.com/nais/msgraph.go/v1.0" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/nais/azureator/pkg/azure/client/application/approle" 11 | "github.com/nais/azureator/pkg/azure/permissions" 12 | ) 13 | 14 | func TestToMap(t *testing.T) { 15 | roles := make([]msgraph.AppRole, 0) 16 | role1 := approle.NewGenerateId("role1") 17 | role2 := approle.NewGenerateId("role2") 18 | role3 := approle.NewGenerateId("role3") 19 | role3Duplicate := approle.NewGenerateId("role3") 20 | roles = append(roles, role1) 21 | roles = append(roles, role2) 22 | roles = append(roles, role3) 23 | roles = append(roles, role3Duplicate) 24 | 25 | appRoleMap := approle.ToMap(roles) 26 | 27 | assert.Len(t, appRoleMap, 3) 28 | assert.Equal(t, role1, appRoleMap["role1"]) 29 | assert.Equal(t, role2, appRoleMap["role2"]) 30 | assert.Equal(t, role3, appRoleMap["role3"]) 31 | } 32 | 33 | func TestMap_Add(t *testing.T) { 34 | role1 := approle.NewGenerateId("role1") 35 | role2 := approle.NewGenerateId("role2") 36 | role3 := approle.NewGenerateId("role3") 37 | role4 := approle.NewGenerateId("role4") 38 | 39 | appRoleMap := make(approle.Map) 40 | appRoleMap.Add(role1) 41 | appRoleMap.Add(role2) 42 | appRoleMap.Add(role3) 43 | appRoleMap.Add(role4) 44 | 45 | // duplicate roles should not be added 46 | role1Duplicate := approle.NewGenerateId("role1") 47 | appRoleMap.Add(role1Duplicate) 48 | 49 | assert.Len(t, appRoleMap, 4) 50 | assert.Equal(t, role1, appRoleMap["role1"]) 51 | assert.Equal(t, role2, appRoleMap["role2"]) 52 | assert.Equal(t, role3, appRoleMap["role3"]) 53 | assert.Equal(t, role4, appRoleMap["role4"]) 54 | } 55 | 56 | func TestMap_ToSlice(t *testing.T) { 57 | role1 := approle.NewGenerateId("role1") 58 | role2 := approle.NewGenerateId("role2") 59 | role3 := approle.NewGenerateId("role3") 60 | 61 | appRoleMap := make(approle.Map) 62 | 63 | appRoleMap.Add(role1) 64 | appRoleMap.Add(role2) 65 | appRoleMap.Add(role3) 66 | 67 | slice := appRoleMap.ToSlice() 68 | assert.Contains(t, slice, role1) 69 | assert.Contains(t, slice, role2) 70 | assert.Contains(t, slice, role3) 71 | assert.Len(t, slice, 3) 72 | } 73 | 74 | func TestMap_ToCreate(t *testing.T) { 75 | t.Run("with existing roles", func(t *testing.T) { 76 | existing := make(approle.Map) 77 | existing.Add(approle.NewGenerateId("existing-role-1")) 78 | existing.Add(approle.NewGenerateId("existing-role-2")) 79 | existing.Add(approle.DefaultRole()) 80 | 81 | desired := make(permissions.Permissions) 82 | desired.Add(permissions.NewGenerateIdEnabled("existing-role-1")) 83 | desired.Add(permissions.NewGenerateIdEnabled("role-2")) 84 | desired.Add(permissions.NewGenerateIdEnabled("role-3")) 85 | 86 | toCreate := existing.ToCreate(desired) 87 | 88 | assert.Len(t, toCreate, 2) 89 | // should contain new roles to be created 90 | assert.Equal(t, approle.FromPermission(desired["role-2"]), toCreate["role-2"]) 91 | assert.Equal(t, approle.FromPermission(desired["role-3"]), toCreate["role-3"]) 92 | // should not contain default role 93 | assert.Empty(t, toCreate[permissions.DefaultAppRoleValue]) 94 | }) 95 | 96 | t.Run("without existing roles should add default role", func(t *testing.T) { 97 | existing := make(approle.Map) 98 | 99 | desired := make(permissions.Permissions) 100 | desired.Add(permissions.NewGenerateIdEnabled("role-1")) 101 | 102 | toCreate := existing.ToCreate(desired) 103 | assert.Len(t, toCreate, 2) 104 | // should contain the new roles to be created 105 | assert.Equal(t, approle.FromPermission(desired["role-1"]), toCreate["role-1"]) 106 | // should contain default role if not in existing 107 | assert.Equal(t, approle.DefaultRole(), toCreate[permissions.DefaultAppRoleValue]) 108 | }) 109 | } 110 | 111 | func TestMap_ToDisable(t *testing.T) { 112 | existing := make(approle.Map) 113 | existing.Add(approle.NewGenerateId("existing-role-1")) 114 | existing.Add(approle.NewGenerateId("existing-role-2")) 115 | 116 | desired := make(permissions.Permissions) 117 | desired.Add(permissions.NewGenerateIdEnabled("existing-role-1")) 118 | desired.Add(permissions.NewGenerateIdEnabled("role-2")) 119 | 120 | toDisable := existing.ToDisable(desired) 121 | 122 | assert.Len(t, toDisable, 1) 123 | 124 | // should contain non-desired roles which should be disabled 125 | nonDesired := existing["existing-role-2"] 126 | nonDesired.IsEnabled = ptr.Bool(false) 127 | 128 | assert.Equal(t, nonDesired, toDisable["existing-role-2"]) 129 | 130 | // should not disable default role 131 | assert.NotContains(t, toDisable, approle.DefaultRole()) 132 | } 133 | 134 | func TestMap_Unmodified(t *testing.T) { 135 | existing := make(approle.Map) 136 | existingRole1 := approle.NewGenerateId("existing-role-1") 137 | existingRole1.AllowedMemberTypes = []string{"Application", "User"} 138 | existingRole1.Description = ptr.String("non standard description") 139 | existingRole1.DisplayName = ptr.String("non standard display name") 140 | existingRole1.IsEnabled = ptr.Bool(false) 141 | existingRole1.Origin = ptr.String("some origin") 142 | existing.Add(existingRole1) 143 | existing.Add(approle.NewGenerateId("existing-role-2")) 144 | 145 | desired := make(permissions.Permissions) 146 | desired.Add(permissions.NewGenerateIdEnabled("existing-role-1")) 147 | desired.Add(permissions.NewGenerateIdEnabled("role-2")) 148 | 149 | toCreate := existing.ToCreate(desired) 150 | toDisable := existing.ToDisable(desired) 151 | 152 | unmodified := existing.Unmodified(toCreate, toDisable) 153 | 154 | assert.Len(t, unmodified, 1) 155 | // should contain non-modified roles 156 | assert.Equal(t, *existing["existing-role-1"].ID, *unmodified["existing-role-1"].ID) 157 | assert.Equal(t, "existing-role-1", *unmodified["existing-role-1"].Value) 158 | 159 | // unmodified roles should conform to standard 160 | assert.Equal(t, "existing-role-1", *unmodified["existing-role-1"].Description) 161 | assert.Equal(t, "existing-role-1", *unmodified["existing-role-1"].DisplayName) 162 | assert.Nil(t, unmodified["existing-role-1"].Origin) 163 | assert.True(t, *unmodified["existing-role-1"].IsEnabled) 164 | assert.Len(t, unmodified["existing-role-1"].AllowedMemberTypes, 1) 165 | assert.Contains(t, unmodified["existing-role-1"].AllowedMemberTypes, "Application") 166 | } 167 | -------------------------------------------------------------------------------- /pkg/azure/client/approleassignment/approleassignment_test.go: -------------------------------------------------------------------------------- 1 | package approleassignment_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/uuid" 7 | msgraph "github.com/nais/msgraph.go/v1.0" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/nais/azureator/pkg/azure/client/approleassignment" 11 | ) 12 | 13 | var ( 14 | assignment1 = appRoleAssignment("role-id-1", "assignee-id-1", "target-id") 15 | assignment2 = appRoleAssignment("role-id-1", "assignee-id-2", "target-id") 16 | 17 | assignment3 = appRoleAssignment("role-id-1", "assignee-id-3", "target-id") 18 | assignment3Duplicate = appRoleAssignment("role-id-1", "assignee-id-3", "target-id") 19 | 20 | assignment4 = appRoleAssignment("role-id-2", "assignee-id-3", "target-id") 21 | ) 22 | 23 | func TestDifference(t *testing.T) { 24 | t.Run("Same elements in both sets should return empty", func(t *testing.T) { 25 | a := approleassignment.List{randomAppRoleAssignment()} 26 | b := a 27 | diff := approleassignment.Difference(a, b) 28 | assert.Empty(t, diff) 29 | }) 30 | 31 | t.Run("Empty sets should return empty", func(t *testing.T) { 32 | a := make(approleassignment.List, 0) 33 | b := make(approleassignment.List, 0) 34 | diff := approleassignment.Difference(a, b) 35 | assert.Empty(t, diff) 36 | }) 37 | 38 | t.Run("Disjoint sets should return all elements in A", func(t *testing.T) { 39 | a := approleassignment.List{randomAppRoleAssignment()} 40 | b := approleassignment.List{randomAppRoleAssignment()} 41 | diff := approleassignment.Difference(a, b) 42 | assert.NotEmpty(t, diff) 43 | assert.ElementsMatch(t, diff, a) 44 | }) 45 | 46 | t.Run("Elements in A not in B should return relative complement of A in B", func(t *testing.T) { 47 | common := appRoleAssignment("test", "test", "test") 48 | common2 := randomAppRoleAssignment() 49 | revoked := approleassignment.List{randomAppRoleAssignment(), randomAppRoleAssignment()} 50 | a := append(revoked, common, common2) 51 | b := approleassignment.List{common, common2, randomAppRoleAssignment()} 52 | diff := approleassignment.Difference(a, b) 53 | assert.NotEmpty(t, diff) 54 | assert.NotContains(t, diff, common) 55 | assert.NotContains(t, diff, common2) 56 | assert.ElementsMatch(t, diff, revoked) 57 | }) 58 | } 59 | 60 | func TestToAssign(t *testing.T) { 61 | t.Run("should return all assignments in desired if none matching in existing", func(t *testing.T) { 62 | existing := approleassignment.List{assignment1, assignment2} 63 | desired := approleassignment.List{assignment3, assignment4} 64 | 65 | toAssign := approleassignment.ToAssign(existing, desired) 66 | assert.Len(t, toAssign, 2) 67 | assert.Contains(t, toAssign, assignment3) 68 | assert.Contains(t, toAssign, assignment4) 69 | assert.NotContains(t, toAssign, assignment1) 70 | assert.NotContains(t, toAssign, assignment2) 71 | }) 72 | 73 | t.Run("should only add assignments in desired that are not in existing", func(t *testing.T) { 74 | existing := approleassignment.List{assignment1, assignment2} 75 | desired := approleassignment.List{assignment1, assignment2, assignment3, assignment4} 76 | 77 | toAssign := approleassignment.ToAssign(existing, desired) 78 | assert.Len(t, toAssign, 2) 79 | assert.Contains(t, toAssign, assignment3) 80 | assert.Contains(t, toAssign, assignment4) 81 | assert.NotContains(t, toAssign, assignment1) 82 | assert.NotContains(t, toAssign, assignment2) 83 | }) 84 | 85 | t.Run("should not be marked for assignment if desired assignment already in existing", func(t *testing.T) { 86 | existing := approleassignment.List{assignment3} 87 | desired := approleassignment.List{assignment3, assignment3Duplicate} 88 | 89 | toAssign := approleassignment.ToAssign(existing, desired) 90 | assert.Len(t, toAssign, 0) 91 | assert.NotContains(t, toAssign, assignment3) 92 | assert.NotContains(t, toAssign, assignment3Duplicate) 93 | }) 94 | } 95 | 96 | func TestToRevoke(t *testing.T) { 97 | t.Run("should return all assignments in existing if none matching in desired", func(t *testing.T) { 98 | existing := approleassignment.List{assignment1, assignment2} 99 | desired := approleassignment.List{assignment3, assignment4} 100 | 101 | toAssign := approleassignment.ToRevoke(existing, desired) 102 | assert.Len(t, toAssign, 2) 103 | assert.Contains(t, toAssign, assignment1) 104 | assert.Contains(t, toAssign, assignment2) 105 | assert.NotContains(t, toAssign, assignment3) 106 | assert.NotContains(t, toAssign, assignment4) 107 | }) 108 | 109 | t.Run("should only revoke assignments in existing that are not in desired", func(t *testing.T) { 110 | existing := approleassignment.List{assignment1, assignment2, assignment3, assignment4} 111 | desired := approleassignment.List{assignment1, assignment2} 112 | 113 | toAssign := approleassignment.ToRevoke(existing, desired) 114 | assert.Len(t, toAssign, 2) 115 | assert.Contains(t, toAssign, assignment3) 116 | assert.Contains(t, toAssign, assignment4) 117 | assert.NotContains(t, toAssign, assignment1) 118 | assert.NotContains(t, toAssign, assignment2) 119 | }) 120 | 121 | t.Run("should not be marked for revocation if existing assignment already in desired", func(t *testing.T) { 122 | existing := approleassignment.List{assignment3} 123 | desired := approleassignment.List{assignment3, assignment3Duplicate} 124 | 125 | toAssign := approleassignment.ToRevoke(existing, desired) 126 | assert.Len(t, toAssign, 0) 127 | assert.NotContains(t, toAssign, assignment3) 128 | assert.NotContains(t, toAssign, assignment3Duplicate) 129 | }) 130 | } 131 | 132 | func TestUnmodified(t *testing.T) { 133 | existing := approleassignment.List{assignment1, assignment2} 134 | toAssign := approleassignment.List{assignment3} 135 | toRevoke := approleassignment.List{assignment2} 136 | 137 | unmodified := approleassignment.Unmodified(existing, toAssign, toRevoke) 138 | assert.Len(t, unmodified, 1) 139 | assert.Contains(t, unmodified, assignment1) 140 | assert.NotContains(t, unmodified, assignment2) 141 | assert.NotContains(t, unmodified, assignment3) 142 | } 143 | 144 | func appRoleAssignment(appRoleId string, principalId string, resourceId string) msgraph.AppRoleAssignment { 145 | return msgraph.AppRoleAssignment{ 146 | AppRoleID: (*msgraph.UUID)(&appRoleId), 147 | PrincipalID: (*msgraph.UUID)(&principalId), 148 | ResourceID: (*msgraph.UUID)(&resourceId), 149 | } 150 | } 151 | 152 | func randomAppRoleAssignment() msgraph.AppRoleAssignment { 153 | return appRoleAssignment(uuid.New().String(), uuid.New().String(), uuid.New().String()) 154 | } 155 | --------------------------------------------------------------------------------