Add Entra security group access restriction for SSO

This commit is contained in:
Ivo Oskamp 2026-02-23 14:30:42 +01:00
parent 6bf81bd730
commit b992d6382a
6 changed files with 83 additions and 1 deletions

View File

@ -48,6 +48,7 @@ def _entra_effective_config() -> dict:
client_secret = (getattr(settings, "entra_client_secret", None) or "").strip() if settings else "" client_secret = (getattr(settings, "entra_client_secret", None) or "").strip() if settings else ""
redirect_uri = (getattr(settings, "entra_redirect_uri", None) or "").strip() if settings else "" redirect_uri = (getattr(settings, "entra_redirect_uri", None) or "").strip() if settings else ""
allowed_domain = (getattr(settings, "entra_allowed_domain", None) or "").strip().lower() if settings else "" allowed_domain = (getattr(settings, "entra_allowed_domain", None) or "").strip().lower() if settings else ""
allowed_group_ids = (getattr(settings, "entra_allowed_group_ids", None) or "").strip() if settings else ""
auto_provision = bool(getattr(settings, "entra_auto_provision_users", False)) if settings else False auto_provision = bool(getattr(settings, "entra_auto_provision_users", False)) if settings else False
if not tenant_id: if not tenant_id:
@ -66,6 +67,8 @@ def _entra_effective_config() -> dict:
if not auto_provision: if not auto_provision:
env_auto = (os.environ.get("ENTRA_AUTO_PROVISION_USERS", "") or "").strip().lower() env_auto = (os.environ.get("ENTRA_AUTO_PROVISION_USERS", "") or "").strip().lower()
auto_provision = env_auto in ("1", "true", "yes", "on") auto_provision = env_auto in ("1", "true", "yes", "on")
if not allowed_group_ids:
allowed_group_ids = (os.environ.get("ENTRA_ALLOWED_GROUP_IDS", "") or "").strip()
return { return {
"enabled": enabled, "enabled": enabled,
@ -74,10 +77,23 @@ def _entra_effective_config() -> dict:
"client_secret": client_secret, "client_secret": client_secret,
"redirect_uri": redirect_uri, "redirect_uri": redirect_uri,
"allowed_domain": allowed_domain, "allowed_domain": allowed_domain,
"allowed_group_ids": allowed_group_ids,
"auto_provision": auto_provision, "auto_provision": auto_provision,
} }
def _parse_group_ids(raw: str | None) -> set[str]:
if not raw:
return set()
normalized = raw.replace("\n", ",").replace(";", ",")
out = set()
for item in normalized.split(","):
value = (item or "").strip()
if value:
out.add(value.lower())
return out
def _b64url_decode(data: str) -> bytes: def _b64url_decode(data: str) -> bytes:
pad = "=" * (-len(data) % 4) pad = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode((data + pad).encode("ascii")) return base64.urlsafe_b64decode((data + pad).encode("ascii"))
@ -138,6 +154,15 @@ def _resolve_sso_user(claims: dict, auto_provision: bool) -> User | None:
return new_user return new_user
def _groups_from_claims(claims: dict) -> set[str]:
groups = claims.get("groups")
if isinstance(groups, list):
return {str(x).strip().lower() for x in groups if str(x).strip()}
if isinstance(groups, str) and groups.strip():
return {groups.strip().lower()}
return set()
def captcha_required(func): def captcha_required(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
@ -322,6 +347,32 @@ def entra_callback():
flash("Your Microsoft account is not allowed for this instance.", "danger") flash("Your Microsoft account is not allowed for this instance.", "danger")
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
allowed_groups = _parse_group_ids(cfg.get("allowed_group_ids"))
if allowed_groups:
claim_names = claims.get("_claim_names") or {}
groups_overage = isinstance(claim_names, dict) and "groups" in claim_names
token_groups = _groups_from_claims(claims)
if groups_overage:
flash(
"Group-based access check could not be completed because token group overage is active. "
"Limit group claims to assigned groups or reduce memberships.",
"danger",
)
return redirect(url_for("auth.login"))
if not token_groups:
flash(
"Group-based access is enabled, but no groups claim was received from Microsoft Entra. "
"Configure group claims in the Entra app token settings.",
"danger",
)
return redirect(url_for("auth.login"))
if token_groups.isdisjoint(allowed_groups):
flash("Your Microsoft account is not in an allowed security group.", "danger")
return redirect(url_for("auth.login"))
user = _resolve_sso_user(claims, auto_provision=bool(cfg.get("auto_provision"))) user = _resolve_sso_user(claims, auto_provision=bool(cfg.get("auto_provision")))
if not user: if not user:
flash( flash(

View File

@ -948,6 +948,8 @@ def settings():
settings.entra_redirect_uri = (request.form.get("entra_redirect_uri") or "").strip() or None settings.entra_redirect_uri = (request.form.get("entra_redirect_uri") or "").strip() or None
if "entra_allowed_domain" in request.form: if "entra_allowed_domain" in request.form:
settings.entra_allowed_domain = (request.form.get("entra_allowed_domain") or "").strip() or None settings.entra_allowed_domain = (request.form.get("entra_allowed_domain") or "").strip() or None
if "entra_allowed_group_ids" in request.form:
settings.entra_allowed_group_ids = (request.form.get("entra_allowed_group_ids") or "").strip() or None
if "entra_client_secret" in request.form: if "entra_client_secret" in request.form:
pw = (request.form.get("entra_client_secret") or "").strip() pw = (request.form.get("entra_client_secret") or "").strip()
if pw: if pw:

View File

@ -1190,6 +1190,7 @@ def migrate_entra_sso_settings() -> None:
("entra_client_secret", "VARCHAR(255) NULL"), ("entra_client_secret", "VARCHAR(255) NULL"),
("entra_redirect_uri", "VARCHAR(512) NULL"), ("entra_redirect_uri", "VARCHAR(512) NULL"),
("entra_allowed_domain", "VARCHAR(255) NULL"), ("entra_allowed_domain", "VARCHAR(255) NULL"),
("entra_allowed_group_ids", "TEXT NULL"),
("entra_auto_provision_users", "BOOLEAN NOT NULL DEFAULT FALSE"), ("entra_auto_provision_users", "BOOLEAN NOT NULL DEFAULT FALSE"),
] ]

View File

@ -134,6 +134,7 @@ class SystemSettings(db.Model):
entra_client_secret = db.Column(db.String(255), nullable=True) entra_client_secret = db.Column(db.String(255), nullable=True)
entra_redirect_uri = db.Column(db.String(512), nullable=True) entra_redirect_uri = db.Column(db.String(512), nullable=True)
entra_allowed_domain = db.Column(db.String(255), nullable=True) entra_allowed_domain = db.Column(db.String(255), nullable=True)
entra_allowed_group_ids = db.Column(db.Text, nullable=True) # comma/newline separated Entra Group Object IDs
entra_auto_provision_users = db.Column(db.Boolean, nullable=False, default=False) entra_auto_provision_users = db.Column(db.Boolean, nullable=False, default=False)
# Autotask integration settings # Autotask integration settings

View File

@ -58,12 +58,33 @@
<li><strong>Client Secret</strong></li> <li><strong>Client Secret</strong></li>
<li><strong>Redirect URI</strong> (optional override, leave empty to auto-use callback URL)</li> <li><strong>Redirect URI</strong> (optional override, leave empty to auto-use callback URL)</li>
<li><strong>Allowed domain/tenant</strong> (optional restriction)</li> <li><strong>Allowed domain/tenant</strong> (optional restriction)</li>
<li><strong>Allowed Entra Group Object ID(s)</strong> (optional but recommended)</li>
</ul> </ul>
</li> </li>
<li>Optional: enable <strong>Auto-provision unknown users as Viewer</strong>.</li> <li>Optional: enable <strong>Auto-provision unknown users as Viewer</strong>.</li>
<li>Save settings.</li> <li>Save settings.</li>
</ol> </ol>
<h2>Security Group Restriction (recommended)</h2>
<p>You can enforce that only members of one or more specific Entra security groups can sign in.</p>
<ol>
<li>Create or choose a security group in Entra (for example <code>Backupchecks-Users</code>).</li>
<li>Add the allowed users to that group.</li>
<li>Copy the group <strong>Object ID</strong> (not display name).</li>
<li>Paste one or more group object IDs in:
<ul>
<li><strong>Settings → Integrations → Microsoft Entra SSO → Allowed Entra Group Object ID(s)</strong></li>
</ul>
</li>
<li>In the Entra app registration, configure <strong>Token configuration</strong> to include the <code>groups</code> claim in ID tokens.</li>
</ol>
<div class="doc-callout doc-callout-warning">
<strong>Important:</strong> if users are member of many groups, Entra may return a "group overage" token without inline
<code>groups</code> list. In that case Backupchecks cannot verify membership and login is blocked by design.
</div>
<h2>Step 5: Test sign-in</h2> <h2>Step 5: Test sign-in</h2>
<ol> <ol>
<li>Open <strong>/auth/login</strong> in a private/incognito browser session.</li> <li>Open <strong>/auth/login</strong> in a private/incognito browser session.</li>
@ -88,7 +109,7 @@
<li><strong>Redirect URI mismatch:</strong> ensure Entra app URI exactly matches Backupchecks callback URI.</li> <li><strong>Redirect URI mismatch:</strong> ensure Entra app URI exactly matches Backupchecks callback URI.</li>
<li><strong>SSO button not visible:</strong> check that SSO is enabled and Tenant/Client/Secret are saved.</li> <li><strong>SSO button not visible:</strong> check that SSO is enabled and Tenant/Client/Secret are saved.</li>
<li><strong>Account not allowed:</strong> verify tenant/domain restriction in <em>Allowed domain/tenant</em>.</li> <li><strong>Account not allowed:</strong> verify tenant/domain restriction in <em>Allowed domain/tenant</em>.</li>
<li><strong>Group restricted login fails:</strong> verify group object IDs and ensure the ID token includes a <code>groups</code> claim.</li>
<li><strong>No local user mapping:</strong> create a matching local user or enable auto-provision.</li> <li><strong>No local user mapping:</strong> create a matching local user or enable auto-provision.</li>
</ul> </ul>
{% endblock %} {% endblock %}

View File

@ -655,6 +655,12 @@
value="{{ settings.entra_allowed_domain or '' }}" placeholder="contoso.com or tenant-id" /> value="{{ settings.entra_allowed_domain or '' }}" placeholder="contoso.com or tenant-id" />
<div class="form-text">Restrict sign-ins to one tenant id or one email domain.</div> <div class="form-text">Restrict sign-ins to one tenant id or one email domain.</div>
</div> </div>
<div class="col-md-12">
<label for="entra_allowed_group_ids" class="form-label">Allowed Entra Group Object ID(s) (optional)</label>
<textarea class="form-control" id="entra_allowed_group_ids" name="entra_allowed_group_ids" rows="3"
placeholder="group-object-id-1&#10;group-object-id-2">{{ settings.entra_allowed_group_ids or '' }}</textarea>
<div class="form-text">Optional hard access gate. Enter one or more Entra security group object IDs (comma or newline separated). User must be member of at least one.</div>
</div>
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check form-switch mt-4"> <div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" id="entra_auto_provision_users" name="entra_auto_provision_users" {% if settings.entra_auto_provision_users %}checked{% endif %} /> <input class="form-check-input" type="checkbox" id="entra_auto_provision_users" name="entra_auto_provision_users" {% if settings.entra_auto_provision_users %}checked{% endif %} />