Ctrl K
ComunidadGuías…

commands/check_oidc_identity_drift.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
Check (and optionally repair) "Sign in with SimpleLogin" identities that do not
belong to the owner of the alias they are linked to.

Usage
-----
    # report only (default, read-only)
    python -m commands.check_oidc_identity_drift

    # additionally revoke the affected identities and their grants
    python -m commands.check_oidc_identity_drift --fix
"""
import argparse
import sys
from typing import List, Optional

from sqlalchemy import func

from app.alias_utils import revoke_client_user
from app.db import Session
from app.models import Alias, Client, ClientUser, User

# how many client_user ids to look at per query
BATCH_SIZE = 1000


class Stats:
    def __init__(self):
        self.found = 0
        self.revoked = 0
        self.errors = 0

    def print(self):
        print("---")
        print(f"inconsistent identities found   : {self.found}")
        print(f"inconsistent identities revoked : {self.revoked}")
        print(f"errors                          : {self.errors}")
        print("---")


def drifted_client_users(batch_start: int, batch_end: int) -> List[ClientUser]:
    """
    ClientUser rows, ie OIDC subjects, linked to an alias owned by somebody
    else: the subject they produce is served to a user who never consented to
    it.
    """
    return (
        Session.query(ClientUser)
        .join(Alias, Alias.id == ClientUser.alias_id)
        .filter(ClientUser.user_id != Alias.user_id)
        .filter(ClientUser.id >= batch_start, ClientUser.id <= batch_end)
        .all()
    )


def describe(client_user: ClientUser) -> str:
    alias: Optional[Alias] = client_user.alias
    alias_owner: Optional[User] = alias.user if alias else None
    previous_owner: Optional[User] = (
        User.get(alias.original_owner_id) if alias and alias.original_owner_id else None
    )
    client: Optional[Client] = client_user.client

    transferred = (
        f", transferred from user {previous_owner.id}" if previous_owner else ""
    )
    return (
        f"client_user id={client_user.id} (OIDC sub={client_user.id}) "
        f"is assigned to user {client_user.user_id} but its alias "
        f"{alias.email if alias else client_user.alias_id} is owned by "
        f"user {alias_owner.id if alias_owner else '?'}{transferred} "
        f"| client={client.name if client else client_user.client_id}"
        f" ({client.oauth_client_id if client else '?'})"
        f" | created_at={client_user.created_at}"
    )


def main() -> int:
    parser = argparse.ArgumentParser(
        prog="check_oidc_identity_drift",
        description=(
            "Find client_user rows, ie OpenID Connect subjects, linked to an alias "
            "owned by another user, usually the leftover of an alias transfer "
            "performed before the issue was fixed"
        ),
    )
    parser.add_argument(
        "--fix",
        action="store_true",
        help="revoke the affected identities and their grants instead of only reporting them",
    )
    args = parser.parse_args()

    stats = Stats()
    max_id = Session.query(func.max(ClientUser.id)).scalar()
    if not max_id:
        print("no client_user row found")
        return 0

    print(
        f"checking client_user ids up to {max_id}, looking for identities linked "
        f"to an alias owned by another user"
    )
    if args.fix:
        print("mode: FIX, the inconsistent identities will be revoked")
    else:
        print("mode: CHECK ONLY, nothing is changed (use --fix to revoke)")

    for batch_start in range(1, max_id + 1, BATCH_SIZE):
        batch_end = min(batch_start + BATCH_SIZE - 1, max_id)
        for client_user in drifted_client_users(batch_start, batch_end):
            stats.found += 1
            print(f"INCONSISTENT: {describe(client_user)}")

            if not args.fix:
                continue

            try:
                # only that identity goes away, the alias itself is untouched
                revoke_client_user(client_user)
                Session.commit()
                stats.revoked += 1
                print(f"REVOKED: client_user {client_user.id}")
            except Exception as e:
                Session.rollback()
                stats.errors += 1
                print(f"ERROR revoking client_user {client_user.id}: {e}")

    stats.print()

    if stats.found and not args.fix:
        print(
            "run again with --fix to revoke those identities. The affected users "
            "will have to authorize the involved apps again, with a new OIDC "
            "subject."
        )
        return 1

    return 0


if __name__ == "__main__":
    sys.exit(main())