Files
cowork-local/core/accounts.py
T
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

207 lines
8.1 KiB
Python

"""Accounts + RBAC — Admin/Sub-admin/User identities shared across machines.
Stored one JSON file per account under ``<shared_dir>/accounts/`` (a plain
shared folder path — network share or a locally-synced OneDrive folder, see
``config.py``'s ``auth.shared_dir``). Deliberately NOT routed through the
Microsoft Graph API: Graph has no write access to an arbitrary share link,
only to the signed-in user's own drive, so a shared mutable store has to be
plain file I/O against a configured path instead.
Login validates a 12-character access code issued by an Admin (``code``),
or — for SSO — an already-verified company identity is matched to an
existing account by username (see ``ms365_auth.py``); SSO never creates an
account on its own, an Admin always provisions it first.
"""
from __future__ import annotations
import json
import re
import secrets
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Set, Tuple
from ..config import CONFIG_DIR
ROLES = ("admin", "subadmin", "user")
# A small LOCAL (never shared-folder) cache of the last successful login's
# identity — username + role only, never the access code — so a laptop that's
# off-VPN/off-network can still open the app as its last-known role. A
# revoked/edited account only takes effect once the shared folder is
# reachable again; see login_dialog.py.
_LAST_LOGIN_PATH = CONFIG_DIR / "last_login.json"
def save_last_login(username: str, role: str) -> None:
"""Nhớ tài khoản đăng nhập gần nhất để lần mở sau điền sẵn."""
try:
_LAST_LOGIN_PATH.parent.mkdir(parents=True, exist_ok=True)
_LAST_LOGIN_PATH.write_text(
json.dumps({"username": username, "role": role}), encoding="utf-8")
except OSError:
pass
def load_last_login() -> Optional[Tuple[str, str]]:
"""Cặp (tên đăng nhập, vai trò) của lần đăng nhập gần nhất; ``None`` nếu chưa có."""
try:
data = json.loads(_LAST_LOGIN_PATH.read_text(encoding="utf-8"))
username, role = data.get("username", ""), data.get("role", "")
if username and role in ROLES:
return username, role
except (OSError, json.JSONDecodeError, TypeError):
pass
return None
# Unambiguous alphanumeric alphabet for issued access codes — excludes
# characters easy to mis-type/mis-read (0/O, 1/I).
_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
CODE_LENGTH = 12
@dataclass
class Account:
"""Một tài khoản người dùng: tên đăng nhập, vai trò, tên hiển thị và nhóm."""
username: str
role: str
display_name: str = ""
department: str = ""
email: str = ""
group_id: str = ""
code: str = ""
created: str = ""
created_by: str = ""
def accounts_dir(shared_dir: str) -> Path:
"""Thư mục chứa tài khoản, nằm trong thư mục chia sẻ của đội."""
return Path(shared_dir).expanduser() / "accounts"
def _safe_username(username: str) -> str:
"""Normalize to lowercase alnum/./- only — matches the login screen's
own auto-lowercase behavior, so a username is a stable, safe filename."""
return re.sub(r"[^\w.\-]", "", (username or "").strip().lower())
def generate_code(existing_codes: Optional[Set[str]] = None) -> str:
"""A random, non-repeating 12-character access code."""
existing = existing_codes or set()
for _ in range(1000):
code = "".join(secrets.choice(_CODE_ALPHABET) for _ in range(CODE_LENGTH))
if code not in existing:
return code
raise RuntimeError("Could not generate a unique access code.")
def save_account(account: Account, directory: Path) -> Path:
"""Ghi một tài khoản ra ``<username>.json`` (tên file đã được làm sạch)."""
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{_safe_username(account.username)}.json"
path.write_text(json.dumps(asdict(account), ensure_ascii=False, indent=2), encoding="utf-8")
return path
def load_account(username: str, directory: Path) -> Optional[Account]:
"""Đọc một tài khoản theo tên đăng nhập; không có thì trả ``None``."""
path = directory / f"{_safe_username(username)}.json"
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
known = {f for f in Account.__dataclass_fields__}
return Account(**{k: v for k, v in data.items() if k in known})
except (OSError, json.JSONDecodeError, TypeError):
return None
def list_accounts(directory: Path) -> List[Account]:
"""Liệt kê mọi tài khoản trong thư mục; thư mục chưa có thì trả list rỗng."""
if not directory.exists():
return []
out: List[Account] = []
for path in sorted(directory.glob("*.json")):
acc = load_account(path.stem, directory)
if acc is not None:
out.append(acc)
out.sort(key=lambda a: a.username)
return out
def delete_account(username: str, directory: Path) -> bool:
"""Xoá file tài khoản; trả về ``True`` nếu có file để xoá."""
path = directory / f"{_safe_username(username)}.json"
try:
path.unlink()
return True
except OSError:
return False
def find_by_username(username: str, directory: Path) -> Optional[Account]:
"""Bí danh của :func:`load_account`, giữ cho mã cũ gọi theo tên này vẫn chạy."""
return load_account(username, directory)
def verify_login(username: str, code: str, directory: Path) -> Optional[Account]:
"""The matching Account when ``username``/``code`` are a valid pair."""
account = load_account(username, directory)
if account is None or not code or not account.code:
return None
return account if account.code == code else None
def new_account(username: str, role: str, display_name: str = "", department: str = "",
email: str = "", group_id: str = "", created_by: str = "",
existing_codes: Optional[Set[str]] = None) -> Account:
"""A fresh Account with a freshly generated, unique access code."""
return Account(
username=_safe_username(username),
role=role if role in ROLES else "user",
display_name=display_name,
department=department,
email=email,
group_id=group_id,
code=generate_code(existing_codes),
created=datetime.now().isoformat(timespec="seconds"),
created_by=created_by,
)
# ---- single-admin invariant -------------------------------------------------
# The app allows exactly ONE account with role="admin" per shared folder. The
# helpers below are how every create/promote path checks and (for the
# first-run bootstrap) atomically claims that slot.
def admin_exists(directory: Path, exclude_username: str = "") -> bool:
"""True when some account other than ``exclude_username`` already holds
the admin role."""
return any(a.role == "admin" and a.username != _safe_username(exclude_username)
for a in list_accounts(directory))
def claim_admin_slot(directory: Path) -> bool:
"""Atomically claim the right to create THE admin account.
``list-then-write`` alone leaves a race window: two never-configured
machines pointed at the same share can both see an empty accounts folder
and both create an "admin". An exclusive-create marker file closes it —
``open(..., "x")`` either succeeds for exactly one caller or raises for
everyone else (also for later callers after a crash mid-bootstrap, which
is fine: the marker plus admin_exists() are both checked by the caller).
Returns True when this caller won the claim."""
directory.mkdir(parents=True, exist_ok=True)
marker = directory / ".admin_claimed"
try:
with open(marker, "x", encoding="utf-8") as fh:
fh.write(datetime.now().isoformat(timespec="seconds"))
return True
except FileExistsError:
return False
except OSError:
# Filesystems that can't do exclusive create (rare) — fall back to
# the plain existence check so bootstrap isn't bricked entirely.
return not admin_exists(directory)