- Go 95.8%
- Dockerfile 4.2%
| .forgejo/workflows | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| main.go | ||
| README.md | ||
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
- Fetch all members of a security group (default:
keeper-users) - Fetch all users currently holding the extension attribute (default:
extensionAttribute10=keeper) - 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
- toSet: members without the attribute → set it to
- Log the plan in JSON (structured for K8s log aggregators)
- Apply the changes (or log what would happen in dry-run mode)
- 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 attributesGroupMember.Read.All(Graph API) — needed to list group members
Steps to Set Up in Azure Portal
- Go to App registrations → create or select your app
- Navigate to API permissions
- Click Add a permission → Microsoft Graph
- Choose Application permissions (not Delegated)
- Search for and select:
User.ReadWrite.AllGroupMember.Read.All
- Click Grant admin consent for [Tenant] (this requires Directory admin role)
Getting Credentials
From the same app registration:
- Navigate to Certificates & secrets
- Click New client secret
- Copy the Value (not the ID) → this is your
AZURE_CLIENT_SECRET - Go to Overview and copy:
- Application (client) ID →
AZURE_CLIENT_ID - Directory (tenant) ID →
AZURE_TENANT_ID
- Application (client) ID →
Kubernetes Deployment
As a CronJob (Recommended)
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/v1Job 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
With .env file (recommended):
# 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/httpandgolang.org/x/oauth2directly 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]