Add operational schema and identity roles

This commit is contained in:
lussierj committed 2026-07-28 13:46:10 +00:00
1 parent f5af75f305
commit f181482463
11 files changed
+2068 -284

No files matched your search

+60 -284
View File
@@ -10,10 +10,18 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, status
from pydantic import BaseModel, EmailStr, Field
from app.config import settings
from app.identity import (
COOKIE_TOKEN_HINT,
SESSION_COOKIE,
get_current_user,
install_identity_middleware,
router as identity_router,
)
app = FastAPI(title=settings.app_name)
password_hasher = PasswordHasher()
app.include_router(identity_router)
install_identity_middleware(app)
def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict:
@@ -74,83 +82,6 @@ def check_redis() -> str:
return f"error: {exc}"
class SetupAdminRequest(BaseModel):
username: str = Field(min_length=3, max_length=64)
display_name: str = Field(min_length=1, max_length=120)
email: EmailStr | None = None
password: str = Field(min_length=10, max_length=256)
class LoginRequest(BaseModel):
username: str
password: str
def create_access_token(user_id: str, username: str, role: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": user_id,
"username": username,
"role": role,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=settings.jwt_access_token_minutes)).timestamp()),
"type": "access",
}
return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256")
def decode_access_token(token: str) -> dict:
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"])
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
def get_bearer_token(authorization: str | None = Header(default=None)) -> str:
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
return authorization.split(" ", 1)[1].strip()
def get_current_user(token: str = Depends(get_bearer_token)) -> dict:
payload = decode_access_token(token)
user_id = payload["sub"]
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
users.id::text AS id,
users.username,
users.display_name,
users.email,
users.is_active,
roles.name AS role
FROM users
JOIN roles ON roles.id = users.role_id
WHERE users.id = %s;
""",
(user_id,),
)
user = cur.fetchone()
if not user or not user["is_active"]:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
return dict(user)
finally:
if conn is not None:
conn.close()
@app.get("/api/health")
def health_check():
database_status = check_database()
@@ -171,196 +102,6 @@ def health_check():
)
@app.get("/api/auth/setup-status")
def setup_status():
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM users;")
user_count = cur.fetchone()[0]
return api_response(
data={
"setup_required": user_count == 0,
"user_count": user_count,
}
)
finally:
if conn is not None:
conn.close()
@app.post("/api/auth/setup-admin")
def setup_admin(request: SetupAdminRequest):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute("SELECT COUNT(*) AS user_count FROM users;")
user_count = cur.fetchone()["user_count"]
if user_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Initial admin setup has already been completed",
)
cur.execute("SELECT id FROM roles WHERE name = 'Admin';")
admin_role = cur.fetchone()
if not admin_role:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Admin role is missing",
)
password_hash = password_hasher.hash(request.password)
cur.execute(
"""
INSERT INTO users (username, display_name, email, password_hash, role_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id::text, username, display_name, email;
""",
(
request.username.strip(),
request.display_name.strip(),
request.email,
password_hash,
admin_role["id"],
),
)
user = cur.fetchone()
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'user.created', 'user', %s, %s);
""",
(
user["id"],
user["id"],
psycopg.types.json.Jsonb(
{
"username": user["username"],
"display_name": user["display_name"],
"role": "Admin",
"source": "setup-admin",
}
),
),
)
conn.commit()
return api_response(
data={
"user_id": user["id"],
"username": user["username"],
"display_name": user["display_name"],
}
)
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.post("/api/auth/login")
def login(request: LoginRequest):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
users.id::text AS id,
users.username,
users.display_name,
users.email,
users.password_hash,
users.is_active,
roles.name AS role
FROM users
JOIN roles ON roles.id = users.role_id
WHERE lower(users.username) = lower(%s);
""",
(request.username.strip(),),
)
user = cur.fetchone()
if not user or not user["is_active"]:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
try:
password_hasher.verify(user["password_hash"], request.password)
except VerifyMismatchError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
access_token = create_access_token(user["id"], user["username"], user["role"])
cur.execute(
"UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s;",
(user["id"],),
)
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'user.login', 'user', %s, %s);
""",
(
user["id"],
user["id"],
psycopg.types.json.Jsonb({"username": user["username"]}),
),
)
conn.commit()
return api_response(
data={
"access_token": access_token,
"token_type": "bearer",
"expires_in": settings.jwt_access_token_minutes * 60,
"user": {
"id": user["id"],
"username": user["username"],
"display_name": user["display_name"],
"email": user["email"],
"role": user["role"],
},
}
)
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.get("/api/auth/me")
def me(current_user: dict = Depends(get_current_user)):
return api_response(data=current_user)
class CreateRoomRequest(BaseModel):
site_id: str | None = None
name: str = Field(min_length=1, max_length=120)
@@ -4303,7 +4044,7 @@ def minerops_ensure_pool_info_table():
cur.execute(
"""
CREATE TABLE IF NOT EXISTS miner_pool_snapshots (
id TEXT PRIMARY KEY,
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
miner_id UUID,
ip_address INET,
mac_address TEXT,
@@ -4326,6 +4067,25 @@ def minerops_ensure_pool_info_table():
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
DECLARE
id_type TEXT;
BEGIN
SELECT data_type INTO id_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'miner_pool_snapshots'
AND column_name = 'id';
IF id_type = 'text' THEN
ALTER TABLE miner_pool_snapshots
ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text;
ELSIF id_type = 'uuid' THEN
ALTER TABLE miner_pool_snapshots
ALTER COLUMN id SET DEFAULT gen_random_uuid();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created
ON miner_pool_snapshots (miner_id, created_at DESC);
@@ -4443,7 +4203,6 @@ async def agent_pool_snapshots(request: Request):
cur.execute(
"""
INSERT INTO miner_pool_snapshots (
id,
miner_id,
ip_address,
mac_address,
@@ -4465,7 +4224,6 @@ async def agent_pool_snapshots(request: Request):
agent_id
)
VALUES (
%s,
%s,
%s::inet,
%s,
@@ -4488,7 +4246,6 @@ async def agent_pool_snapshots(request: Request):
);
""",
(
str(_uuid.uuid4()),
miner_id,
ip,
mac,
@@ -6241,22 +5998,25 @@ MINEROPS_SHELL_AUTH_JS = r"""
}
async function getCurrentUser() {
const token = getToken();
if (!token) {
return null;
}
try {
const response = await fetch("/api/auth/me", {
credentials: "same-origin",
headers: authHeaders()
});
if (!response.ok) {
window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname));
return null;
}
return await response.json();
const payload = await response.json();
const user = payload && payload.data ? payload.data : payload;
if (user && !getToken()) {
setToken("cookie-session");
}
return user;
} catch (error) {
return null;
}
@@ -6352,9 +6112,16 @@ MINEROPS_SHELL_AUTH_JS = r"""
}
}
function logoutFromHeader() {
clearToken();
window.location.reload();
async function logoutFromHeader() {
try {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "same-origin"
});
} finally {
clearToken();
window.location.replace("/login");
}
}
async function renderHeaderAuth() {
@@ -6388,6 +6155,13 @@ MINEROPS_SHELL_AUTH_JS = r"""
<button type="button" id="mineropsHeaderLogout">Logout</button>
`;
if (user.role === "Admin") {
const accountsLink = document.createElement("a");
accountsLink.href = "/accounts";
accountsLink.textContent = "Accounts";
authArea.prepend(accountsLink);
}
document.getElementById("mineropsHeaderLogout").onclick = logoutFromHeader;
return;
}
@@ -23630,9 +23404,11 @@ async def insert_miner_command_event(
def minerops_command_require_bearer(request: Request):
auth_header = request.headers.get("authorization") or ""
cookie_token = request.cookies.get(SESSION_COOKIE) or ""
header_token = auth_header.split(" ", 1)[1].strip() if auth_header.lower().startswith("bearer ") else ""
if not auth_header.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="Login token required.")
if not cookie_token and (not header_token or header_token == COOKIE_TOKEN_HINT):
raise HTTPException(status_code=401, detail="Login required.")
async def minerops_command_user_from_request(request: Request):