Add Entra security group access restriction for SSO
This commit is contained in:
parent
6bf81bd730
commit
b992d6382a
@ -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(
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@ -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 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 %} />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user