Add operational schema and identity roles
This commit is contained in:
1 parent
f5af75f305
commit
f181482463
11 files changed
+2068
-284
No files matched your search
File diff suppressed because it is too large.
Load diff
+60
-284
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user