diff --git a/containers/backupchecks/src/backend/app/auth/routes.py b/containers/backupchecks/src/backend/app/auth/routes.py index 167ed17..39f7e6e 100644 --- a/containers/backupchecks/src/backend/app/auth/routes.py +++ b/containers/backupchecks/src/backend/app/auth/routes.py @@ -48,6 +48,7 @@ def _entra_effective_config() -> dict: 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 "" 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 if not tenant_id: @@ -66,6 +67,8 @@ def _entra_effective_config() -> dict: if not auto_provision: env_auto = (os.environ.get("ENTRA_AUTO_PROVISION_USERS", "") or "").strip().lower() 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 { "enabled": enabled, @@ -74,10 +77,23 @@ def _entra_effective_config() -> dict: "client_secret": client_secret, "redirect_uri": redirect_uri, "allowed_domain": allowed_domain, + "allowed_group_ids": allowed_group_ids, "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: pad = "=" * (-len(data) % 4) 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 +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): @wraps(func) def wrapper(*args, **kwargs): @@ -322,6 +347,32 @@ def entra_callback(): flash("Your Microsoft account is not allowed for this instance.", "danger") 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"))) if not user: flash( diff --git a/containers/backupchecks/src/backend/app/main/routes_settings.py b/containers/backupchecks/src/backend/app/main/routes_settings.py index 7bda08b..9d1bef5 100644 --- a/containers/backupchecks/src/backend/app/main/routes_settings.py +++ b/containers/backupchecks/src/backend/app/main/routes_settings.py @@ -948,6 +948,8 @@ def settings(): settings.entra_redirect_uri = (request.form.get("entra_redirect_uri") or "").strip() or None if "entra_allowed_domain" in request.form: 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: pw = (request.form.get("entra_client_secret") or "").strip() if pw: diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index f8e2fcc..174bc48 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -1190,6 +1190,7 @@ def migrate_entra_sso_settings() -> None: ("entra_client_secret", "VARCHAR(255) NULL"), ("entra_redirect_uri", "VARCHAR(512) NULL"), ("entra_allowed_domain", "VARCHAR(255) NULL"), + ("entra_allowed_group_ids", "TEXT NULL"), ("entra_auto_provision_users", "BOOLEAN NOT NULL DEFAULT FALSE"), ] diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index c2ce53b..78ea23b 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -134,6 +134,7 @@ class SystemSettings(db.Model): entra_client_secret = db.Column(db.String(255), nullable=True) entra_redirect_uri = db.Column(db.String(512), 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) # Autotask integration settings diff --git a/containers/backupchecks/src/templates/documentation/settings/entra-sso.html b/containers/backupchecks/src/templates/documentation/settings/entra-sso.html index 3f941bf..662c5ff 100644 --- a/containers/backupchecks/src/templates/documentation/settings/entra-sso.html +++ b/containers/backupchecks/src/templates/documentation/settings/entra-sso.html @@ -58,12 +58,33 @@
  • Client Secret
  • Redirect URI (optional override, leave empty to auto-use callback URL)
  • Allowed domain/tenant (optional restriction)
  • +
  • Allowed Entra Group Object ID(s) (optional but recommended)
  • Optional: enable Auto-provision unknown users as Viewer.
  • Save settings.
  • +

    Security Group Restriction (recommended)

    +

    You can enforce that only members of one or more specific Entra security groups can sign in.

    + +
      +
    1. Create or choose a security group in Entra (for example Backupchecks-Users).
    2. +
    3. Add the allowed users to that group.
    4. +
    5. Copy the group Object ID (not display name).
    6. +
    7. Paste one or more group object IDs in: + +
    8. +
    9. In the Entra app registration, configure Token configuration to include the groups claim in ID tokens.
    10. +
    + +
    + Important: if users are member of many groups, Entra may return a "group overage" token without inline + groups list. In that case Backupchecks cannot verify membership and login is blocked by design. +
    +

    Step 5: Test sign-in

    1. Open /auth/login in a private/incognito browser session.
    2. @@ -88,7 +109,7 @@
    3. Redirect URI mismatch: ensure Entra app URI exactly matches Backupchecks callback URI.
    4. SSO button not visible: check that SSO is enabled and Tenant/Client/Secret are saved.
    5. Account not allowed: verify tenant/domain restriction in Allowed domain/tenant.
    6. +
    7. Group restricted login fails: verify group object IDs and ensure the ID token includes a groups claim.
    8. No local user mapping: create a matching local user or enable auto-provision.
    9. {% endblock %} - diff --git a/containers/backupchecks/src/templates/main/settings.html b/containers/backupchecks/src/templates/main/settings.html index 5f5cbd1..48fcc55 100644 --- a/containers/backupchecks/src/templates/main/settings.html +++ b/containers/backupchecks/src/templates/main/settings.html @@ -655,6 +655,12 @@ value="{{ settings.entra_allowed_domain or '' }}" placeholder="contoso.com or tenant-id" />
      Restrict sign-ins to one tenant id or one email domain.
      +
      + + +
      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.
      +