Applies license attribute to Entra ID users according to a Entra ID security group.
  • Go 95.8%
  • Dockerfile 4.2%
Find a file
Remo Wenger a04827026a
All checks were successful
/ build (push) Successful in 18s
/ build-release (push) Has been skipped
Remove useless livenessProbe
2026-06-22 15:26:10 +02:00
.forgejo/workflows Add CICD 2026-06-22 11:33:34 +02:00
.dockerignore Init commit 2026-06-22 11:28:40 +02:00
.env.example Add local run information 2026-06-22 12:02:04 +02:00
.gitignore Add local run information 2026-06-22 12:02:04 +02:00
.gitlab-ci.yml Add gitlab CICD 2026-06-22 13:25:58 +02:00
Dockerfile Init commit 2026-06-22 11:28:40 +02:00
go.mod Init commit 2026-06-22 11:28:40 +02:00
go.sum Init commit 2026-06-22 11:28:40 +02:00
main.go Init commit 2026-06-22 11:28:40 +02:00
README.md Remove useless livenessProbe 2026-06-22 15:26:10 +02:00

keeper-attribute-sync

A Kubernetes-native tool that keeps extensionAttribute10 (or any extensionAttribute1..15) in Azure AD synchronized with membership of a configurable security group. Runs as either a CronJob (one-shot sync) or Deployment (continuous loop).

Why This Exists

Keeper Password Manager uses the extensionAttribute10 field in Azure AD to determine which users should be provisioned. The group keeper-users (or a custom group via configuration) defines who has a Keeper license.

This tool bridges the gap: it ensures that all members of the security group have extensionAttribute10=keeper set, and all users not in the group have the attribute cleared. Without it, users removed from the group would retain the attribute and continue to be provisioned in Keeper, creating security and licensing issues.

What It Does

  1. Fetch all members of a security group (default: keeper-users)
  2. Fetch all users currently holding the extension attribute (default: extensionAttribute10=keeper)
  3. Compare the two sets:
    • toSet: members without the attribute → set it to keeper
    • toClear: non-members with the attribute → clear it (null)
    • already_correct: members who already have it → no action needed
  4. Log the plan in JSON (structured for K8s log aggregators)
  5. Apply the changes (or log what would happen in dry-run mode)
  6. Exit with status 0 (success) or 1 (fatal error or partial failure)

Configuration

All configuration is via environment variables. Required variables cause the app to exit with status 1 and an error log if missing.

Variable Required Default Description
AZURE_TENANT_ID Yes Azure AD tenant ID (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
AZURE_CLIENT_ID Yes App registration client ID
AZURE_CLIENT_SECRET Yes App registration client secret
GROUP_NAME No keeper-users Display name of the Azure AD security group to sync
EXTENSION_ATTRIBUTE No extensionAttribute10 Which extension attribute to manage (valid: extensionAttribute1..extensionAttribute15)
EXTENSION_VALUE No keeper The value to set for group members
SYNC_INTERVAL No `` (empty) How often to sync in loop mode. Examples: 5m, 15m, 1h. If empty, runs once and exits (CronJob mode).
DRY_RUN No false Set to true to log actions without making changes. Useful for testing.

Example Configuration

# Run once and exit (CronJob mode)
export AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
export AZURE_CLIENT_ID=yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy
export AZURE_CLIENT_SECRET=secret...
export GROUP_NAME=keeper-users
export EXTENSION_ATTRIBUTE=extensionAttribute10
export EXTENSION_VALUE=keeper
export DRY_RUN=false
# Run in a loop every 15 minutes (Deployment mode)
export SYNC_INTERVAL=15m
# ... other vars as above

Using .env File for Local Development

For convenience during local development, copy .env.example to .env and populate it with your credentials:

cp .env.example .env
# Edit .env with your Azure credentials
# Then load it: source .env && go run .

The .env file is excluded from git (see .gitignore), so your secrets stay safe. Only .env.example is committed with placeholders. See Local Development section for detailed usage examples.

Required Azure App Registration Permissions

The app requires the following Application permissions (not Delegated permissions) in the app registration:

  • User.ReadWrite.All (Graph API) — needed to read and modify user attributes
  • GroupMember.Read.All (Graph API) — needed to list group members

Steps to Set Up in Azure Portal

  1. Go to App registrations → create or select your app
  2. Navigate to API permissions
  3. Click Add a permissionMicrosoft Graph
  4. Choose Application permissions (not Delegated)
  5. Search for and select:
    • User.ReadWrite.All
    • GroupMember.Read.All
  6. Click Grant admin consent for [Tenant] (this requires Directory admin role)

Getting Credentials

From the same app registration:

  1. Navigate to Certificates & secrets
  2. Click New client secret
  3. Copy the Value (not the ID) → this is your AZURE_CLIENT_SECRET
  4. Go to Overview and copy:
    • Application (client) IDAZURE_CLIENT_ID
    • Directory (tenant) IDAZURE_TENANT_ID

Kubernetes Deployment

A CronJob is the standard approach for periodic sync tasks. Kubernetes handles scheduling, retries, and history automatically.

apiVersion: v1
kind: Secret
metadata:
  name: keeper-attribute-sync
  namespace: default
type: Opaque
data:
  tenant-id: <base64-encoded AZURE_TENANT_ID>
  client-id: <base64-encoded AZURE_CLIENT_ID>
  client-secret: <base64-encoded AZURE_CLIENT_SECRET>
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: keeper-attribute-sync
  namespace: default
spec:
  # Run every 15 minutes
  schedule: "*/15 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: keeper-attribute-sync
          restartPolicy: OnFailure
          containers:
          - name: keeper-attribute-sync
            image: <your-registry>/keeper-attribute-sync:latest
            imagePullPolicy: IfNotPresent
            env:
            - name: AZURE_TENANT_ID
              valueFrom:
                secretKeyRef:
                  name: keeper-attribute-sync
                  key: tenant-id
            - name: AZURE_CLIENT_ID
              valueFrom:
                secretKeyRef:
                  name: keeper-attribute-sync
                  key: client-id
            - name: AZURE_CLIENT_SECRET
              valueFrom:
                secretKeyRef:
                  name: keeper-attribute-sync
                  key: client-secret
            - name: GROUP_NAME
              value: "keeper-users"
            - name: EXTENSION_ATTRIBUTE
              value: "extensionAttribute10"
            - name: EXTENSION_VALUE
              value: "keeper"
            # SYNC_INTERVAL is NOT set, so the app runs once per pod
            resources:
              requests:
                memory: "64Mi"
                cpu: "50m"
              limits:
                memory: "256Mi"
                cpu: "500m"

Advantages:

  • Kubernetes handles scheduling (no need for a separate scheduler)
  • Automatic retry on failure (via restartPolicy: OnFailure)
  • Easy to see job history: kubectl describe cronjob keeper-attribute-sync
  • Pod is cleaned up automatically after the job completes
  • Scales to multiple clusters trivially

As a Deployment (Loop Mode)

Use this if you prefer continuous background polling without CronJob infrastructure.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: keeper-attribute-sync
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: keeper-attribute-sync
  template:
    metadata:
      labels:
        app: keeper-attribute-sync
    spec:
      serviceAccountName: keeper-attribute-sync
      containers:
      - name: keeper-attribute-sync
        image: <your-registry>/keeper-attribute-sync:latest
        imagePullPolicy: IfNotPresent
        env:
        - name: AZURE_TENANT_ID
          valueFrom:
            secretKeyRef:
              name: keeper-attribute-sync
              key: tenant-id
        - name: AZURE_CLIENT_ID
          valueFrom:
            secretKeyRef:
              name: keeper-attribute-sync
              key: client-id
        - name: AZURE_CLIENT_SECRET
          valueFrom:
            secretKeyRef:
              name: keeper-attribute-sync
              key: client-secret
        - name: GROUP_NAME
          value: "keeper-users"
        - name: EXTENSION_ATTRIBUTE
          value: "extensionAttribute10"
        - name: EXTENSION_VALUE
          value: "keeper"
        # Run sync every 15 minutes
        - name: SYNC_INTERVAL
          value: "15m"
        resources:
          requests:
            memory: "64Mi"
            cpu: "50m"
          limits:
            memory: "256Mi"
            cpu: "500m"

Advantages:

  • Simpler RBAC (no need for batch/v1 Job permissions)
  • Easier to debug (pod stays running, use kubectl logs -f deployment/keeper-attribute-sync)

Disadvantages:

  • You manage scheduling and retries
  • Pod stays running and consuming resources between syncs
  • Job history not automatically available

Local Development

Prerequisites

  • Go 1.23 or later
  • Valid Azure credentials (test tenant with a security group)

Setup with .env File

The repository includes .env.example with all available configuration options. Use it to create your local .env file:

# Copy the example to create your local .env (excluded from git)
cp .env.example .env

# Edit .env with your Azure credentials and preferences
# ⚠️  Never commit .env — it's in .gitignore to protect secrets
nano .env

The .env file is excluded from git (see .gitignore), so your credentials stay local.

Running

# Load .env and run (dry-run mode — safe for testing)
set -a && source .env && set +a && DRY_RUN=true go run .

# Or with a helper alias (add to ~/.bashrc or ~/.zshrc):
# alias run-keeper='set -a && source .env && set +a && go run .'
# Then just: DRY_RUN=true run-keeper

With inline env vars (for quick testing):

# Set env vars directly (overrides .env)
export AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
export AZURE_CLIENT_ID=yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy
export AZURE_CLIENT_SECRET=secret...

# Run once (dry-run mode — safe for testing)
DRY_RUN=true go run .

# Run in loop mode (every 5 minutes)
SYNC_INTERVAL=5m go run .

Building Locally

# Build the binary
go build -o keeper-attribute-sync .

# Run with .env
set -a && source .env && set +a && ./keeper-attribute-sync

# Or run with inline vars
AZURE_TENANT_ID=... AZURE_CLIENT_ID=... AZURE_CLIENT_SECRET=... ./keeper-attribute-sync

Testing Configuration

Before deploying to Kubernetes, test your configuration locally in dry-run mode:

# Load .env and run in dry-run
set -a && source .env && set +a && DRY_RUN=true go run . 2>&1 | jq .

The JSON log output should show:

  • Which users would have the attribute set
  • Which users would have the attribute cleared
  • No actual changes to Azure AD

Example successful dry-run output:

{
  "time": "2026-06-22T12:00:00Z",
  "level": "INFO",
  "msg": "sync plan",
  "to_set": 3,
  "to_clear": 1,
  "already_correct": 47
}

This means:

  • 3 users need the attribute set (newly added to group)
  • 1 user needs the attribute cleared (removed from group)
  • 47 users already have the correct state (no action needed)

Docker Build

docker build -t keeper-attribute-sync:latest .
docker run --rm \
  -e AZURE_TENANT_ID=... \
  -e AZURE_CLIENT_ID=... \
  -e AZURE_CLIENT_SECRET=... \
  -e DRY_RUN=true \
  keeper-attribute-sync:latest

Logging

The app outputs structured JSON logs to stdout. Each log line is a complete JSON object with fields like:

{
  "time": "2024-06-22T10:30:45Z",
  "level": "INFO",
  "msg": "sync completed successfully",
  "user_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "upn": "user@example.com",
  "attribute": "extensionAttribute10",
  "value": "keeper"
}

Kubernetes log aggregators (Loki, CloudWatch, Datadog, etc.) can parse these fields and make them queryable.

Troubleshooting

"missing required environment variable"

Set all required vars (AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET).

"group not found"

Check that the GROUP_NAME env var matches the display name of your security group exactly (case-sensitive). Use Azure Portal or az ad group list to verify.

"graph API error: 400 / Request_BadRequest"

Usually means the security group or filter is malformed. Verify the group exists and the EXTENSION_ATTRIBUTE is valid (must be one of extensionAttribute1..extensionAttribute15).

"graph API error: 403"

The app registration lacks required permissions. Check that both User.ReadWrite.All and GroupMember.Read.All are granted, and admin consent has been given.

Some users fail to sync but others succeed

The app logs per-user errors and continues. Check the logs for which users failed and why (often due to permission issues on that specific user account). A partial failure returns exit code 1, which triggers a CronJob retry.

Architecture Notes

  • No external SDKs: Uses Go's net/http and golang.org/x/oauth2 directly for minimal dependencies and startup time
  • Raw Graph API: Constructs OData queries manually for transparency and control over pagination
  • Graceful shutdown: Responds to SIGINT/SIGTERM (important for K8s pod termination)
  • Structured logging: JSON output to stdout compatible with all K8s log aggregators
  • Atomic-ish updates: Each user attribute update is atomic at the Graph API level; partial sync failures are logged and trigger retries
  • Efficient diff: Fetches full group and full current-holders, then computes the diff locally (O(n) set operations)
  • Multi-stage Docker: Minimal runtime image (Alpine) with non-root user

License

[Add license info if applicable]

Contributing

[Add contribution guidelines if applicable]