Feat: Add cloud plane
- Plane with OIDC users, projects and VDCs with per-VDC roles, SSH key vault, Cloudflare exposure, TLS certificates, and a gateway that forwards allowlisted calls to core with the VDC as tenant_id. - Also added docker compose for setups
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
CLOUD_DATABASE_URL=mysql+pymysql://cloud_user:cloud_password@cloud-db:3306/cloud
|
||||
|
||||
CORE_API_BASE_URL=http://host.docker.internal:5000
|
||||
CORE_API_KEY=change-me-generate-a-real-key
|
||||
|
||||
FLASK_ENV=development
|
||||
|
||||
# production (default when unset) | development. Only development allows the
|
||||
# auth dev bypass below.
|
||||
APP_ENV=production
|
||||
|
||||
OIDC_ISSUER=https://secuird.tech/
|
||||
OIDC_JWKS_URL=
|
||||
OIDC_AUDIENCE=change-me-oidc-client-id
|
||||
OIDC_ADDITIONAL_AUDIENCES=
|
||||
OIDC_SUBJECT_CLAIM=sub
|
||||
OIDC_EMAIL_CLAIM=email
|
||||
OIDC_ADMIN_GROUP=
|
||||
BOOTSTRAP_ADMIN_EMAILS=
|
||||
|
||||
OIDC_GROUPS_CLAIMS=groups,roles,realm_access.roles,openstack_groups,openstack_project_names,memberOf
|
||||
IDP_PROJECT_AUTOCREATE=true
|
||||
IDP_PROJECT_GROUP_PATTERN=
|
||||
IDP_PROJECT_IGNORED_GROUPS=offline_access,uma_authorization,account,default-roles-*,*/*-realm,everyone,users,authenticated
|
||||
IDP_PROJECT_DEFAULT_ROLE=member
|
||||
IDP_PROJECT_NAME_TEMPLATE={group_title}
|
||||
|
||||
OAUTH2_PROXY_CLIENT_ID=change-me-oidc-client-id
|
||||
OAUTH2_PROXY_CLIENT_SECRET=change-me
|
||||
OAUTH2_PROXY_COOKIE_SECRET=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
|
||||
OAUTH2_PROXY_REDIRECT_URL=http://localhost:8090/oauth2/callback
|
||||
OAUTH2_PROXY_COOKIE_SECURE=false
|
||||
OAUTH2_PROXY_UPSTREAMS=http://cloud-api:5001/api/,http://host.docker.internal:8080/
|
||||
|
||||
# Development only: requests without a token act as LOCAL_USER_EMAIL. On by
|
||||
# default when APP_ENV=development; set false there to test real sign-in.
|
||||
# Ignored (and logged) in any other APP_ENV.
|
||||
AUTH_DEV_BYPASS=
|
||||
LOCAL_USER_EMAIL=local@xcloudify.dev
|
||||
LOCAL_USER_GROUPS=
|
||||
|
||||
CLOUD_BROKER_URL=redis://cloud-redis:6379/0
|
||||
CLOUD_RESULT_BACKEND=redis://cloud-redis:6379/1
|
||||
|
||||
CLOUD_SECRET_KEY=
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential libmariadb-dev curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY packages/pyshared /packages/pyshared
|
||||
RUN pip install --upgrade pip && pip install -e /packages/pyshared
|
||||
|
||||
COPY cloud/requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY cloud/ .
|
||||
|
||||
CMD ["bash"]
|
||||
@@ -0,0 +1,4 @@
|
||||
from app import app # noqa: F401
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, port=5001, host="0.0.0.0")
|
||||
@@ -0,0 +1,123 @@
|
||||
import uuid
|
||||
|
||||
from celery import Celery, Task
|
||||
from flask import Flask, g, request
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_cors import CORS
|
||||
|
||||
from xcloudify_shared import logger
|
||||
from xcloudify_shared.env import dev_bypass_enabled
|
||||
from runtime_urls import (
|
||||
CLOUD_DATABASE_URL, CORE_API_BASE_URL, CORE_API_KEY, LOCAL_USER_EMAIL,
|
||||
REDIS_BROKER_URL, REDIS_RESULT_BACKEND_URL,
|
||||
OIDC_ISSUER, OIDC_JWKS_URL, OIDC_AUDIENCE, OIDC_ADDITIONAL_AUDIENCES,
|
||||
OIDC_SUBJECT_CLAIM, OIDC_EMAIL_CLAIM, APP_ENV, AUTH_DEV_BYPASS, LOCAL_USER_GROUPS,
|
||||
OIDC_GROUPS_CLAIMS, IDP_PROJECT_AUTOCREATE, IDP_PROJECT_GROUP_PATTERN,
|
||||
IDP_PROJECT_IGNORED_GROUPS, IDP_PROJECT_DEFAULT_ROLE, IDP_PROJECT_NAME_TEMPLATE,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app, supports_credentials=True)
|
||||
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = CLOUD_DATABASE_URL
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
app.config["CORE_API_BASE_URL"] = CORE_API_BASE_URL
|
||||
app.config["CORE_API_KEY"] = CORE_API_KEY
|
||||
app.config["LOCAL_USER_EMAIL"] = LOCAL_USER_EMAIL
|
||||
app.config["broker_url"] = REDIS_BROKER_URL
|
||||
app.config["result_backend"] = REDIS_RESULT_BACKEND_URL
|
||||
|
||||
app.config["OIDC_ISSUER"] = OIDC_ISSUER
|
||||
app.config["OIDC_JWKS_URL"] = OIDC_JWKS_URL
|
||||
app.config["OIDC_AUDIENCE"] = OIDC_AUDIENCE
|
||||
app.config["OIDC_ADDITIONAL_AUDIENCES"] = OIDC_ADDITIONAL_AUDIENCES
|
||||
app.config["OIDC_SUBJECT_CLAIM"] = OIDC_SUBJECT_CLAIM
|
||||
app.config["OIDC_EMAIL_CLAIM"] = OIDC_EMAIL_CLAIM
|
||||
app.config["APP_ENV"] = APP_ENV
|
||||
app.config["AUTH_DEV_BYPASS"] = dev_bypass_enabled(APP_ENV, AUTH_DEV_BYPASS)
|
||||
if app.config["AUTH_DEV_BYPASS"]:
|
||||
logger.warning("AUTH DEV BYPASS ON (APP_ENV=%s): unauthenticated requests act as %s", APP_ENV, LOCAL_USER_EMAIL)
|
||||
app.config["LOCAL_USER_GROUPS"] = LOCAL_USER_GROUPS
|
||||
app.config["OIDC_GROUPS_CLAIMS"] = OIDC_GROUPS_CLAIMS
|
||||
app.config["IDP_PROJECT_AUTOCREATE"] = IDP_PROJECT_AUTOCREATE
|
||||
app.config["IDP_PROJECT_GROUP_PATTERN"] = IDP_PROJECT_GROUP_PATTERN
|
||||
app.config["IDP_PROJECT_IGNORED_GROUPS"] = IDP_PROJECT_IGNORED_GROUPS
|
||||
app.config["IDP_PROJECT_DEFAULT_ROLE"] = IDP_PROJECT_DEFAULT_ROLE
|
||||
app.config["IDP_PROJECT_NAME_TEMPLATE"] = IDP_PROJECT_NAME_TEMPLATE
|
||||
|
||||
if not CORE_API_KEY:
|
||||
logger.error("CORE_API_KEY is empty -- every gateway call to core will be rejected")
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
migrate = Migrate(app, db)
|
||||
|
||||
|
||||
def _make_celery(flask_app: Flask) -> Celery:
|
||||
"""Same pattern as core/app/__init__.py: tasks inherit the Flask app context."""
|
||||
celery = Celery(
|
||||
flask_app.import_name,
|
||||
broker=flask_app.config["broker_url"],
|
||||
backend=flask_app.config["result_backend"],
|
||||
)
|
||||
celery.conf.update(flask_app.config)
|
||||
|
||||
class ContextTask(Task):
|
||||
abstract = True
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
with flask_app.app_context():
|
||||
return super().__call__(*args, **kwargs)
|
||||
|
||||
celery.Task = ContextTask
|
||||
return celery
|
||||
|
||||
|
||||
celery_app: Celery = _make_celery(app)
|
||||
celery_app.autodiscover_tasks(["app.tasks"], force=True)
|
||||
app.extensions["celery"] = celery_app
|
||||
|
||||
from app import models # noqa: E402 needed for db.metadata / migrations
|
||||
|
||||
_PUBLIC_PATHS = {"/api/healthz"}
|
||||
|
||||
|
||||
@app.before_request
|
||||
def assign_request_id():
|
||||
g.request_id = str(uuid.uuid4())
|
||||
|
||||
|
||||
@app.before_request
|
||||
def enforce_authentication():
|
||||
if request.method == "OPTIONS":
|
||||
return
|
||||
from app.auth_utils import authenticate_request
|
||||
authenticate_request()
|
||||
|
||||
path = request.path
|
||||
if not path.startswith("/api") or path in _PUBLIC_PATHS:
|
||||
return
|
||||
if g.current_user is None:
|
||||
from xcloudify_shared import api_response
|
||||
return api_response(success=False, status=401, message="Authentication required", error_type="UNAUTHORIZED")
|
||||
|
||||
|
||||
@app.after_request
|
||||
def log_request_id(response):
|
||||
response.headers["X-Request-ID"] = g.request_id
|
||||
return response
|
||||
|
||||
|
||||
from app.routes import api_bp # noqa: E402
|
||||
|
||||
|
||||
@api_bp.route("/healthz", methods=["GET"])
|
||||
def healthz():
|
||||
from flask import jsonify
|
||||
return jsonify("ok")
|
||||
|
||||
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
|
||||
logger.debug("cloud app init complete")
|
||||
@@ -0,0 +1,158 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from flask import current_app, g, has_request_context, request as flask_request
|
||||
|
||||
from xcloudify_shared import logger
|
||||
from xcloudify_shared.oidc import OIDCVerifier, bearer_token
|
||||
|
||||
LOCAL_USER_EMAIL = "local@xcloudify.dev"
|
||||
|
||||
|
||||
def _verifier() -> OIDCVerifier:
|
||||
verifier = current_app.extensions.get("oidc_verifier")
|
||||
if verifier is None:
|
||||
verifier = OIDCVerifier.from_config(current_app.config, user_agent="xcloudify-cloud")
|
||||
current_app.extensions["oidc_verifier"] = verifier
|
||||
return verifier
|
||||
|
||||
|
||||
def _link_pending_invites(user) -> None:
|
||||
"""A VdcMember invite is addressed to an email before that person has an
|
||||
account (see models.VdcMember). Resolve any waiting on this email now
|
||||
that they've logged in."""
|
||||
from app import db
|
||||
from app.models import ProjectMember, VdcMember
|
||||
|
||||
pending = (
|
||||
VdcMember.query.filter_by(email=user.email, user_id=None).all()
|
||||
+ ProjectMember.query.filter_by(email=user.email, user_id=None).all()
|
||||
)
|
||||
if not pending:
|
||||
return
|
||||
for member in pending:
|
||||
member.user_id = user.id
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def upsert_user_from_claims(claims: dict):
|
||||
from app import db
|
||||
from app.models import User
|
||||
|
||||
subject_claim = current_app.config.get("OIDC_SUBJECT_CLAIM", "sub")
|
||||
email_claim = current_app.config.get("OIDC_EMAIL_CLAIM", "email")
|
||||
oidc_id = str(claims.get(subject_claim) or claims["sub"])
|
||||
email = (claims.get(email_claim) or f"{oidc_id}@users.noreply").strip().lower()
|
||||
full_name = (claims.get("name") or "").strip()
|
||||
given = claims.get("given_name") or (full_name.split(" ")[0] if full_name else None)
|
||||
family = claims.get("family_name") or (" ".join(full_name.split(" ")[1:]) if full_name else None)
|
||||
|
||||
user = User.query.filter_by(oidc_id=oidc_id).first() or User.query.filter_by(email=email).first()
|
||||
is_new = user is None
|
||||
if user is None:
|
||||
user = User(oidc_id=oidc_id, email=email, first_name=given, last_name=family)
|
||||
db.session.add(user)
|
||||
else:
|
||||
user.oidc_id = oidc_id
|
||||
user.email = email
|
||||
if given:
|
||||
user.first_name = given
|
||||
if family:
|
||||
user.last_name = family
|
||||
|
||||
if claims.get("picture"):
|
||||
user.avatar_url = claims["picture"]
|
||||
|
||||
now = datetime.utcnow()
|
||||
fresh_login = user.last_login_at is None or (now - user.last_login_at) > timedelta(minutes=5)
|
||||
if fresh_login:
|
||||
user.last_login_at = now
|
||||
|
||||
db.session.flush()
|
||||
_link_pending_invites(user)
|
||||
db.session.commit()
|
||||
_provision_default_project(user, is_new)
|
||||
if is_new or fresh_login:
|
||||
_sync_idp_projects(user, claims)
|
||||
return user
|
||||
|
||||
|
||||
def _sync_idp_projects(user, claims: dict) -> None:
|
||||
from app import db
|
||||
from app.idp_projects import sync_idp_projects
|
||||
|
||||
try:
|
||||
sync_idp_projects(user, claims)
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
logger.exception("Could not sync IdP group projects for %s", user.email)
|
||||
|
||||
|
||||
def _provision_default_project(user, is_new: bool) -> None:
|
||||
"""Give a brand-new account the project its VDCs will live in.
|
||||
|
||||
Only on the login that creates the row. A project is what
|
||||
POST /projects/<id>/vdcs needs to exist before the user can create
|
||||
anything at all, and nothing in the portal's bootstrap
|
||||
(/auth/me, then /virtual_data_centers) would otherwise reach the lazy
|
||||
provisioner in project_routes.list_projects -- so a first-time OIDC user
|
||||
landed in a workspace with no project, no VDC, and no way to make either.
|
||||
|
||||
Deliberately not attempted on every login: an account whose project was
|
||||
intentionally removed should not have it silently reappear. Accounts that
|
||||
predate this still get one from list_projects' ensure_default_project.
|
||||
"""
|
||||
if not is_new:
|
||||
return
|
||||
from app import db
|
||||
from app.authz import ensure_default_project
|
||||
|
||||
try:
|
||||
ensure_default_project(user)
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
logger.exception("Could not provision a default project for %s", user.email)
|
||||
|
||||
|
||||
def _dev_user():
|
||||
from app import db
|
||||
from app.models import User
|
||||
|
||||
email = current_app.config.get("LOCAL_USER_EMAIL", LOCAL_USER_EMAIL).strip().lower()
|
||||
user = User.query.filter_by(email=email).first()
|
||||
is_new = user is None
|
||||
if user is None:
|
||||
user = User(oidc_id=f"dev:{email}", email=email,
|
||||
first_name="Dev", last_name="User", is_platform_admin=True)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
_link_pending_invites(user)
|
||||
user.last_login_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
_provision_default_project(user, is_new)
|
||||
groups = [g.strip() for g in (current_app.config.get("LOCAL_USER_GROUPS") or "").split(",") if g.strip()]
|
||||
_sync_idp_projects(user, {"groups": groups})
|
||||
return user
|
||||
|
||||
|
||||
def authenticate_request():
|
||||
g.current_user = None
|
||||
token = bearer_token(flask_request)
|
||||
if token:
|
||||
verifier = _verifier()
|
||||
try:
|
||||
g.current_user = upsert_user_from_claims(verifier.verify(token))
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"OIDC token verification failed on %s: %s (expected aud=%s iss=%s)",
|
||||
flask_request.path, exc, verifier.audiences, verifier.issuer,
|
||||
)
|
||||
if current_app.config.get("AUTH_DEV_BYPASS"):
|
||||
g.current_user = _dev_user()
|
||||
|
||||
|
||||
def get_request_user_id() -> Optional[str]:
|
||||
if has_request_context() and getattr(g, "current_user", None):
|
||||
return g.current_user.id
|
||||
return None
|
||||
@@ -0,0 +1,232 @@
|
||||
from functools import wraps
|
||||
|
||||
from flask import g
|
||||
|
||||
from app.models import (
|
||||
MEMBER_SOURCE_IDP, PROJECT_KIND_ORGANIZATION, PROJECT_KIND_PERSONAL,
|
||||
PROJECT_KIND_SHARED, PROJECT_ROLE_MEMBER, PROJECT_ROLE_OWNER,
|
||||
PROJECT_ROLE_VIEWER, PROJECT_ROLES, Project, ProjectMember, ROLE_READ,
|
||||
ROLE_WRITE, Vdc, VdcMember,
|
||||
)
|
||||
from xcloudify_shared import api_response
|
||||
|
||||
|
||||
def _user():
|
||||
return getattr(g, "current_user", None)
|
||||
|
||||
|
||||
def require_auth(fn):
|
||||
"""401 unless a request has resolved to a user.
|
||||
|
||||
Belt-and-suspenders: app/__init__.py's before_request hook already
|
||||
rejects an unauthenticated call to any /api/* route before it reaches
|
||||
here. Kept as an explicit, local statement of intent on the routes that
|
||||
use it.
|
||||
"""
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if _user() is None:
|
||||
return api_response(success=False, status=401, message="Authentication required", error_type="UNAUTHORIZED")
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def _role_rank(role) -> int:
|
||||
try:
|
||||
return PROJECT_ROLES.index(role)
|
||||
except ValueError:
|
||||
return -1
|
||||
|
||||
|
||||
def project_role(user, project) -> str:
|
||||
if user is None:
|
||||
return None
|
||||
if not isinstance(project, Project):
|
||||
if not project:
|
||||
return None
|
||||
project = Project.query.get(project)
|
||||
if project is None:
|
||||
return None
|
||||
|
||||
if user.is_platform_admin:
|
||||
return PROJECT_ROLE_OWNER
|
||||
if project.created_by == user.id and project.kind != PROJECT_KIND_ORGANIZATION:
|
||||
return PROJECT_ROLE_OWNER
|
||||
|
||||
member = ProjectMember.query.filter_by(project_id=project.id, user_id=user.id).first()
|
||||
return member.role if member else None
|
||||
|
||||
|
||||
def require_project_role(role: str = PROJECT_ROLE_OWNER, param: str = "project_id"):
|
||||
def decorator(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = _user()
|
||||
if user is None:
|
||||
return api_response(success=False, status=401, message="Authentication required", error_type="UNAUTHORIZED")
|
||||
held = project_role(user, kwargs.get(param))
|
||||
if held is None:
|
||||
return api_response(success=False, status=404, message="Project not found", error_type="NOT_FOUND")
|
||||
if _role_rank(held) < _role_rank(role):
|
||||
return api_response(
|
||||
success=False, status=403,
|
||||
message=f"This action needs '{role}' on the project; you hold '{held}'",
|
||||
error_type="FORBIDDEN",
|
||||
)
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def require_project_owner(param="project_id"):
|
||||
return require_project_role(PROJECT_ROLE_OWNER, param)
|
||||
|
||||
|
||||
_PROJECT_ROLE_TO_VDC_ROLE = {
|
||||
PROJECT_ROLE_OWNER: ROLE_WRITE,
|
||||
PROJECT_ROLE_MEMBER: ROLE_WRITE,
|
||||
PROJECT_ROLE_VIEWER: ROLE_READ,
|
||||
}
|
||||
|
||||
|
||||
def vdc_role(user, vdc_id: str):
|
||||
"""The role `user` holds on `vdc_id`, or None if they have no access at
|
||||
all (including when the VDC does not exist -- callers should 404 rather
|
||||
than 403 on None, so a guess at another tenant's id can't be confirmed
|
||||
to exist just from the error code)."""
|
||||
if user is None:
|
||||
return None
|
||||
vdc = Vdc.query.get(vdc_id)
|
||||
if vdc is None:
|
||||
return None
|
||||
if user.is_platform_admin:
|
||||
return ROLE_WRITE
|
||||
|
||||
held = _PROJECT_ROLE_TO_VDC_ROLE.get(project_role(user, vdc.project))
|
||||
if held == ROLE_WRITE:
|
||||
return ROLE_WRITE
|
||||
|
||||
member = VdcMember.query.filter_by(vdc_id=vdc_id, user_id=user.id).first()
|
||||
if member is not None:
|
||||
return ROLE_WRITE if member.role == ROLE_WRITE else (held or member.role)
|
||||
return held
|
||||
|
||||
|
||||
def require_vdc_role(role: str, param: str = "vdc_id"):
|
||||
"""401 with no session, 404 with no access (see vdc_role docstring for
|
||||
why not 403), 403 if the held role doesn't cover what's required."""
|
||||
def decorator(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = _user()
|
||||
if user is None:
|
||||
return api_response(success=False, status=401, message="Authentication required", error_type="UNAUTHORIZED")
|
||||
held = vdc_role(user, kwargs.get(param))
|
||||
if held is None:
|
||||
return api_response(success=False, status=404, message="VDC not found", error_type="NOT_FOUND")
|
||||
if role == ROLE_WRITE and held != ROLE_WRITE:
|
||||
return api_response(success=False, status=403, message="Read-only access to this VDC", error_type="FORBIDDEN")
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def visible_projects(user):
|
||||
"""Projects a user created, plus any project containing a VDC they hold
|
||||
membership on. A platform admin sees every project."""
|
||||
if user is None:
|
||||
return []
|
||||
if user.is_platform_admin:
|
||||
return Project.query.order_by(Project.created_at.asc()).all()
|
||||
|
||||
project_ids = {p_id for (p_id,) in (
|
||||
ProjectMember.query
|
||||
.filter(ProjectMember.user_id == user.id)
|
||||
.with_entities(ProjectMember.project_id)
|
||||
.distinct().all()
|
||||
)}
|
||||
project_ids |= {p_id for (p_id,) in (
|
||||
Vdc.query.join(VdcMember, VdcMember.vdc_id == Vdc.id)
|
||||
.filter(VdcMember.user_id == user.id)
|
||||
.with_entities(Vdc.project_id)
|
||||
.distinct().all()
|
||||
)}
|
||||
|
||||
owned = Project.query.filter(
|
||||
Project.created_by == user.id,
|
||||
Project.kind != PROJECT_KIND_ORGANIZATION,
|
||||
).all()
|
||||
project_ids -= {p.id for p in owned}
|
||||
extra = Project.query.filter(Project.id.in_(project_ids)).all() if project_ids else []
|
||||
return sorted(owned + extra, key=lambda p: (p.created_at is None, p.created_at))
|
||||
|
||||
|
||||
def describe_project(project, user) -> dict:
|
||||
data = project.to_json()
|
||||
role = project_role(user, project)
|
||||
member = None
|
||||
if user is not None:
|
||||
member = ProjectMember.query.filter_by(project_id=project.id, user_id=user.id).first()
|
||||
|
||||
if member is not None and member.source == MEMBER_SOURCE_IDP:
|
||||
via = f"Member of '{member.idp_group}'" if member.idp_group else "Identity provider group"
|
||||
elif member is not None:
|
||||
via = "Invited"
|
||||
elif (user is not None and project.created_by == user.id
|
||||
and project.kind != PROJECT_KIND_ORGANIZATION):
|
||||
via = "Your default project" if project.kind == PROJECT_KIND_PERSONAL else "You own this project"
|
||||
elif user is not None and user.is_platform_admin:
|
||||
via = "Platform administrator"
|
||||
else:
|
||||
via = "Invited to a data center in this project"
|
||||
|
||||
data["role"] = role
|
||||
data["via"] = via
|
||||
data["is_default"] = bool(
|
||||
user is not None and project.created_by == user.id and project.kind == PROJECT_KIND_PERSONAL
|
||||
)
|
||||
data["can_manage"] = role == PROJECT_ROLE_OWNER
|
||||
data["vdc_count"] = Vdc.query.filter_by(project_id=project.id).count()
|
||||
if role != PROJECT_ROLE_OWNER:
|
||||
for key in ("cloudflare_account_id", "cloudflare_zone_id", "cloudflare_verified_at"):
|
||||
data.pop(key, None)
|
||||
return data
|
||||
|
||||
|
||||
def ensure_default_project(user) -> Project:
|
||||
"""Auto-provision a project the first time there isn't one.
|
||||
|
||||
One tenant per user for now: this is the only way a Project gets
|
||||
created outside an explicit POST /projects, and POST /projects itself
|
||||
refuses a second one (see project_routes.create_project) -- so every
|
||||
user has exactly one project, created lazily on first need rather than
|
||||
at login, so a user who never opens the console never gets one.
|
||||
"""
|
||||
existing = (
|
||||
Project.query
|
||||
.filter_by(created_by=user.id, kind=PROJECT_KIND_PERSONAL)
|
||||
.order_by(Project.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
project = Project(name=f"{user.name}'s Project", created_by=user.id, kind=PROJECT_KIND_PERSONAL)
|
||||
from app import db
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
return project
|
||||
|
||||
|
||||
def resolve_active_project(user):
|
||||
if user is None:
|
||||
return None
|
||||
if user.active_project_id:
|
||||
project = Project.query.get(user.active_project_id)
|
||||
if project is not None and project_role(user, project) is not None:
|
||||
return project
|
||||
|
||||
projects = visible_projects(user)
|
||||
personal = next(
|
||||
(p for p in projects if p.created_by == user.id and p.kind == PROJECT_KIND_PERSONAL), None,
|
||||
)
|
||||
return personal or (projects[0] if projects else ensure_default_project(user))
|
||||
@@ -0,0 +1,19 @@
|
||||
from xcloudify_shared import logger
|
||||
|
||||
from app import celery_app as celery # noqa: F401 single source-of-truth
|
||||
|
||||
logger.info("Using existing Celery instance from app.__init__")
|
||||
|
||||
celery.conf.beat_schedule.update(
|
||||
{
|
||||
"cloudflare-reconciliation-5m": {
|
||||
"task": "tasks.cloud_cloudflare_reconciliation",
|
||||
"schedule": 300.0,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
__all__ = ["celery"]
|
||||
|
||||
import app.tasks.dns_exposure # noqa: E402
|
||||
import app.tasks.cloudflare_reconciliation # noqa: E402
|
||||
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
Certificate Authority, Certificate, and Certificate Revocation List models.
|
||||
|
||||
xCloudify data model:
|
||||
- Project ⟷ CertificateAuthority is 1:1 (one project has one CA).
|
||||
- CertificateAuthority ⟶ CertificateRevocationList is 1:N (many CRLs per CA).
|
||||
- CertificateAuthority ⟷ current_crl is 1:1 pointer (exactly one CRL "attached" at a time).
|
||||
- CertificateAuthority ⟶ Certificates is 1:N.
|
||||
|
||||
Notes:
|
||||
- Sensitive key material is never exposed by to_json().
|
||||
- Cascades ensure dependent rows are cleaned up on delete.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
BigInteger, Column, String, Boolean, DateTime, ForeignKey, Integer, Text,
|
||||
UniqueConstraint, Index
|
||||
)
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
from sqlalchemy.orm import relationship, backref
|
||||
from app.models import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CertificateAuthority(BaseModel):
|
||||
"""
|
||||
Represents a Certificate Authority bound 1:1 to a Project.
|
||||
|
||||
Relationships
|
||||
-------------
|
||||
project : Project
|
||||
One-to-one relationship. Each Project has exactly one CA.
|
||||
certificates : list[Certificate]
|
||||
One-to-many. A CA may issue multiple Certificates.
|
||||
crls : list[CertificateRevocationList]
|
||||
One-to-many. A CA can publish many CRLs historically.
|
||||
current_crl : CertificateRevocationList | None
|
||||
One-to-one pointer to the currently "attached" CRL.
|
||||
"""
|
||||
|
||||
__tablename__ = "certificate_authorities"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# One CA per project: uniqueness enforces 1:1
|
||||
project_id = Column(String(36), ForeignKey("projects.id"), nullable=False, unique=True)
|
||||
|
||||
common_name = Column(String(255), nullable=False)
|
||||
country = Column(String(2), nullable=True)
|
||||
state = Column(String(255), nullable=True)
|
||||
city = Column(String(255), nullable=True)
|
||||
organization = Column(String(255), nullable=True)
|
||||
organizational_unit = Column(String(255), nullable=True)
|
||||
domain_name = Column(String(255), nullable=True)
|
||||
validity_period = Column(Integer, default=5, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# CA material (keep encrypted at rest; never expose in API)
|
||||
private_key = Column(LONGTEXT, nullable=True) # Encrypted private key
|
||||
certificate_data = Column(LONGTEXT, nullable=True) # PEM of CA cert
|
||||
|
||||
serial_number = Column(String(255), nullable=True)
|
||||
|
||||
|
||||
|
||||
# ---------- Relationships ----------
|
||||
project = relationship(
|
||||
"Project",
|
||||
backref=backref("certificate_authority", uselist=False),
|
||||
foreign_keys=[project_id]
|
||||
)
|
||||
|
||||
certificates = relationship(
|
||||
"Certificate",
|
||||
back_populates="ca",
|
||||
foreign_keys="Certificate.ca_id",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True
|
||||
)
|
||||
|
||||
# In CertificateAuthority
|
||||
current_crl_id = Column(
|
||||
String(36),
|
||||
ForeignKey("certificate_revocation_lists.id", name="fk_ca_current_crl", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
current_crl = relationship(
|
||||
"CertificateRevocationList",
|
||||
foreign_keys=[current_crl_id],
|
||||
uselist=False,
|
||||
post_update=True,
|
||||
)
|
||||
|
||||
|
||||
def to_json(self):
|
||||
"""
|
||||
Serialize the CertificateAuthority to a JSON-friendly dict.
|
||||
|
||||
Sensitive fields (keys, PEM) are removed. Timestamps are ISO-8601.
|
||||
Includes the id of the currently attached CRL (if any).
|
||||
"""
|
||||
result = super().to_json()
|
||||
|
||||
# Strip sensitive or large fields
|
||||
result.pop("private_key", None)
|
||||
result.pop("certificate_data", None)
|
||||
|
||||
# ISO timestamps
|
||||
result["created_at"] = self.created_at.isoformat() if self.created_at else None
|
||||
result["updated_at"] = self.updated_at.isoformat() if self.updated_at else None
|
||||
|
||||
# Convenience: expose current_crl_id without the PEM
|
||||
result["current_crl_id"] = self.current_crl_id
|
||||
|
||||
logger.debug("Serialized CertificateAuthority %s", result.get("id"))
|
||||
return result
|
||||
|
||||
|
||||
class Certificate(BaseModel):
|
||||
"""
|
||||
Represents an issued certificate under a Certificate Authority.
|
||||
|
||||
Constraints
|
||||
-----------
|
||||
- (ca_id, serial_number) is unique to prevent duplicate serials per CA.
|
||||
"""
|
||||
|
||||
__tablename__ = "certificates"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
ca_id = Column(String(36), ForeignKey("certificate_authorities.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
certificate_type = Column(String(50), nullable=False) # e.g., server, client, code_signing
|
||||
common_name = Column(String(255), nullable=False)
|
||||
country = Column(String(2), nullable=True)
|
||||
state = Column(String(255), nullable=True)
|
||||
city = Column(String(255), nullable=True)
|
||||
organization = Column(String(255), nullable=True)
|
||||
organizational_unit = Column(String(255), nullable=True)
|
||||
email = Column(String(255), nullable=True)
|
||||
validity_period = Column(Integer, default=1, nullable=False) # years
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
revoked = Column(Boolean, default=False, nullable=False)
|
||||
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
serial_number = Column(BigInteger, nullable=True)
|
||||
|
||||
# Certificate material (store encrypted where applicable)
|
||||
public_key = Column(LONGTEXT, nullable=True)
|
||||
private_key = Column(LONGTEXT, nullable=True)
|
||||
certificate_data = Column(LONGTEXT, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ca_id", "serial_number", name="uq_cert_ca_serial"),
|
||||
Index("ix_cert_ca_serial", "ca_id", "serial_number"),
|
||||
)
|
||||
|
||||
ca = relationship(
|
||||
"CertificateAuthority",
|
||||
back_populates="certificates",
|
||||
foreign_keys=[ca_id]
|
||||
)
|
||||
|
||||
def to_json(self):
|
||||
"""
|
||||
Serialize the Certificate to a JSON-friendly dict.
|
||||
|
||||
Removes key material and PEM by default. Includes ISO timestamps.
|
||||
"""
|
||||
result = super().to_json()
|
||||
result.pop("public_key", None)
|
||||
result.pop("private_key", None)
|
||||
result.pop("certificate_data", None)
|
||||
|
||||
result["revoked_at"] = self.revoked_at.isoformat() if self.revoked_at else None
|
||||
result["created_at"] = self.created_at.isoformat() if self.created_at else None
|
||||
result["updated_at"] = self.updated_at.isoformat() if self.updated_at else None
|
||||
|
||||
logger.debug("Serialized Certificate %s", result.get("id"))
|
||||
return result
|
||||
|
||||
|
||||
class CertificateRevocationList(BaseModel):
|
||||
"""
|
||||
Represents a CRL published by a Certificate Authority.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- Many CRLs per CA are allowed (historical list via `crls`).
|
||||
- The CA optionally points to the *current* CRL via `current_crl_id`.
|
||||
"""
|
||||
|
||||
__tablename__ = "certificate_revocation_lists"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
crl_number = Column(Integer, nullable=False)
|
||||
crl_data = Column(LONGTEXT, nullable=True) # PEM
|
||||
next_update = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
def to_json(self):
|
||||
"""
|
||||
Serialize the CRL to a JSON-friendly dict.
|
||||
|
||||
Excludes the PEM by default to keep payloads small.
|
||||
"""
|
||||
result = super().to_json()
|
||||
|
||||
# Keep responses lean; use a dedicated endpoint for the PEM if needed.
|
||||
result.pop("crl_data", None)
|
||||
|
||||
result["next_update"] = self.next_update.isoformat() if self.next_update else None
|
||||
result["created_at"] = self.created_at.isoformat() if self.created_at else None
|
||||
result["updated_at"] = self.updated_at.isoformat() if self.updated_at else None
|
||||
|
||||
logger.debug("Serialized CRL %s", result.get("id"))
|
||||
return result
|
||||
@@ -0,0 +1,22 @@
|
||||
from xcloudify_shared import logger
|
||||
from xcloudify_shared.core_client import app_core_request as core_request
|
||||
|
||||
|
||||
def list_regions() -> list:
|
||||
resp = core_request("GET", "regions")
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("data", [])
|
||||
|
||||
|
||||
def list_images() -> list:
|
||||
resp = core_request("GET", "images")
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("data", [])
|
||||
|
||||
|
||||
def region_exists(region_id: str) -> bool:
|
||||
try:
|
||||
return any(r.get("id") == region_id for r in list_regions())
|
||||
except Exception as exc:
|
||||
logger.error("Failed to validate region_id=%s against core: %s", region_id, exc)
|
||||
return False
|
||||
@@ -0,0 +1,7 @@
|
||||
from runtime_urls import CLOUD_SECRET_KEY
|
||||
from xcloudify_shared.crypto import SecretBox
|
||||
|
||||
_box = SecretBox(CLOUD_SECRET_KEY, "CLOUD_SECRET_KEY")
|
||||
|
||||
encrypt_secret = _box.encrypt
|
||||
decrypt_secret = _box.decrypt
|
||||
@@ -0,0 +1,193 @@
|
||||
import fnmatch
|
||||
import re
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from xcloudify_shared import logger
|
||||
|
||||
|
||||
def _config(key: str, default=None):
|
||||
return current_app.config.get(key, default)
|
||||
|
||||
|
||||
def _claim_path(claims: dict, path: str):
|
||||
node = claims
|
||||
for part in path.split("."):
|
||||
if not isinstance(node, dict):
|
||||
return None
|
||||
node = node.get(part)
|
||||
if node is None:
|
||||
return None
|
||||
return node
|
||||
|
||||
|
||||
def _as_group_list(value) -> list:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
parts = value.split() if " " in value else [value]
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
parts = [str(v) for v in value if v is not None]
|
||||
else:
|
||||
return []
|
||||
|
||||
names = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "=" in part and "," in part:
|
||||
part = part.split(",")[0].split("=", 1)[1].strip()
|
||||
part = part.lstrip("/")
|
||||
if part:
|
||||
names.append(part)
|
||||
return names
|
||||
|
||||
|
||||
def extract_groups(claims: dict) -> list:
|
||||
paths = [p.strip() for p in (_config("OIDC_GROUPS_CLAIMS") or "").split(",") if p.strip()]
|
||||
seen, groups = set(), []
|
||||
for path in paths:
|
||||
for name in _as_group_list(_claim_path(claims, path)):
|
||||
key = name.lower()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
groups.append(name)
|
||||
return groups
|
||||
|
||||
|
||||
def _ignored(group: str) -> bool:
|
||||
patterns = [p.strip().lower() for p in (_config("IDP_PROJECT_IGNORED_GROUPS") or "").split(",") if p.strip()]
|
||||
name = group.lower()
|
||||
return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
|
||||
|
||||
|
||||
def _matches_pattern(group: str) -> bool:
|
||||
pattern = (_config("IDP_PROJECT_GROUP_PATTERN") or "").strip()
|
||||
if not pattern:
|
||||
return True
|
||||
try:
|
||||
return re.search(pattern, group) is not None
|
||||
except re.error:
|
||||
logger.error("IDP_PROJECT_GROUP_PATTERN is not a valid regex: %r", pattern)
|
||||
return True
|
||||
|
||||
|
||||
def project_name_for_group(group: str) -> str:
|
||||
words = re.split(r"[\s._\-/]+", group.strip())
|
||||
title = " ".join(w[:1].upper() + w[1:] for w in words if w) or group
|
||||
template = _config("IDP_PROJECT_NAME_TEMPLATE") or "{group_title}"
|
||||
try:
|
||||
return template.format(group=group, group_title=title)[:255]
|
||||
except (KeyError, IndexError):
|
||||
logger.error("IDP_PROJECT_NAME_TEMPLATE is not a valid template: %r", template)
|
||||
return title[:255]
|
||||
|
||||
|
||||
def _default_role() -> str:
|
||||
from app.models import PROJECT_ROLES, PROJECT_ROLE_MEMBER
|
||||
|
||||
role = (_config("IDP_PROJECT_DEFAULT_ROLE") or PROJECT_ROLE_MEMBER).strip().lower()
|
||||
return role if role in PROJECT_ROLES else PROJECT_ROLE_MEMBER
|
||||
|
||||
|
||||
def _binding_for(group: str, owner):
|
||||
from app import db
|
||||
from app.models import (
|
||||
PROJECT_KIND_ORGANIZATION, Project, ProjectGroupBinding,
|
||||
)
|
||||
|
||||
name = group.lower()
|
||||
binding = ProjectGroupBinding.query.filter_by(group_name=name).first()
|
||||
if binding is not None:
|
||||
return binding
|
||||
if not _config("IDP_PROJECT_AUTOCREATE"):
|
||||
return None
|
||||
if _ignored(group) or not _matches_pattern(group):
|
||||
return None
|
||||
|
||||
project = Project(
|
||||
name=project_name_for_group(group),
|
||||
kind=PROJECT_KIND_ORGANIZATION,
|
||||
description=f"Provisioned from the '{group}' group in your identity provider.",
|
||||
created_by=owner.id,
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.flush()
|
||||
binding = ProjectGroupBinding(
|
||||
project_id=project.id, group_name=name, role=_default_role(), auto_created=True,
|
||||
)
|
||||
db.session.add(binding)
|
||||
db.session.flush()
|
||||
logger.info("Created organization project %r from IdP group %r", project.name, group)
|
||||
return binding
|
||||
|
||||
|
||||
def sync_idp_projects(user, claims: dict) -> list:
|
||||
from app import db
|
||||
from app.models import MEMBER_SOURCE_IDP, ProjectMember
|
||||
|
||||
groups = extract_groups(claims)
|
||||
|
||||
wanted = {}
|
||||
for group in groups:
|
||||
binding = _binding_for(group, user)
|
||||
if binding is None:
|
||||
continue
|
||||
current = wanted.get(binding.project_id)
|
||||
if current is None or _rank(binding.role) > _rank(current[0]):
|
||||
wanted[binding.project_id] = (binding.role, group)
|
||||
|
||||
existing = ProjectMember.query.filter_by(user_id=user.id, source=MEMBER_SOURCE_IDP).all()
|
||||
changed = False
|
||||
|
||||
for member in existing:
|
||||
target = wanted.pop(member.project_id, None)
|
||||
if target is None:
|
||||
logger.info("Revoking %s's IdP access to project %s (group %r no longer present)",
|
||||
user.email, member.project_id, member.idp_group)
|
||||
db.session.delete(member)
|
||||
changed = True
|
||||
continue
|
||||
role, group = target
|
||||
if member.role != role or member.idp_group != group:
|
||||
member.role, member.idp_group = role, group
|
||||
changed = True
|
||||
|
||||
for project_id, (role, group) in wanted.items():
|
||||
authored = ProjectMember.query.filter_by(project_id=project_id, email=user.email).first()
|
||||
if authored is not None:
|
||||
continue
|
||||
db.session.add(ProjectMember(
|
||||
project_id=project_id, email=user.email, user_id=user.id,
|
||||
role=role, source=MEMBER_SOURCE_IDP, idp_group=group,
|
||||
))
|
||||
changed = True
|
||||
logger.info("Granted %s %s on project %s via IdP group %r", user.email, role, project_id, group)
|
||||
|
||||
if changed:
|
||||
db.session.commit()
|
||||
return [g for g in groups]
|
||||
|
||||
|
||||
def _rank(role: Optional[str]) -> int:
|
||||
from app.models import PROJECT_ROLES
|
||||
|
||||
try:
|
||||
return PROJECT_ROLES.index(role)
|
||||
except ValueError:
|
||||
return -1
|
||||
|
||||
|
||||
def groups_without_projects(groups: Iterable[str]) -> list:
|
||||
from app.models import ProjectGroupBinding
|
||||
|
||||
names = {g.lower(): g for g in groups}
|
||||
if not names:
|
||||
return []
|
||||
bound = {
|
||||
b.group_name for b in
|
||||
ProjectGroupBinding.query.filter(ProjectGroupBinding.group_name.in_(names.keys())).all()
|
||||
}
|
||||
return [original for lower, original in names.items() if lower not in bound]
|
||||
@@ -0,0 +1,432 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, ForeignKey, Boolean, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
def _uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=_uuid)
|
||||
oidc_id = Column(String(255), unique=True, nullable=False)
|
||||
email = Column(String(255), unique=True, nullable=False)
|
||||
first_name = Column(String(255), nullable=True)
|
||||
last_name = Column(String(255), nullable=True)
|
||||
avatar_url = Column(String(512), nullable=True)
|
||||
is_platform_admin = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
last_login_at = Column(DateTime, nullable=True)
|
||||
active_project_id = Column(String(36), nullable=True)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
full = f"{self.first_name or ''} {self.last_name or ''}".strip()
|
||||
return full or self.email
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"email": self.email,
|
||||
"name": self.name,
|
||||
"avatar_url": self.avatar_url,
|
||||
"is_platform_admin": self.is_platform_admin,
|
||||
"active_project_id": self.active_project_id,
|
||||
}
|
||||
|
||||
|
||||
PROJECT_KIND_PERSONAL = "personal"
|
||||
PROJECT_KIND_SHARED = "shared"
|
||||
PROJECT_KIND_ORGANIZATION = "organization"
|
||||
PROJECT_KINDS = (PROJECT_KIND_PERSONAL, PROJECT_KIND_SHARED, PROJECT_KIND_ORGANIZATION)
|
||||
|
||||
PROJECT_ROLE_VIEWER = "viewer"
|
||||
PROJECT_ROLE_MEMBER = "member"
|
||||
PROJECT_ROLE_OWNER = "owner"
|
||||
PROJECT_ROLES = (PROJECT_ROLE_VIEWER, PROJECT_ROLE_MEMBER, PROJECT_ROLE_OWNER)
|
||||
|
||||
MEMBER_SOURCE_INVITE = "invite"
|
||||
MEMBER_SOURCE_IDP = "idp"
|
||||
|
||||
|
||||
class Project(db.Model):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=_uuid)
|
||||
name = Column(String(255), nullable=False)
|
||||
universe_id = Column(String(36), ForeignKey("universes.id"), nullable=True)
|
||||
created_by = Column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
kind = Column(String(20), nullable=False, default=PROJECT_KIND_PERSONAL)
|
||||
description = Column(String(512), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
cloudflare_api_token_encrypted = Column(LONGTEXT, nullable=True)
|
||||
cloudflare_account_id = Column(String(255), nullable=True)
|
||||
cloudflare_zone_id = Column(String(255), nullable=True)
|
||||
cloudflare_domain = Column(String(255), nullable=True)
|
||||
cloudflare_verified_at = Column(DateTime, nullable=True)
|
||||
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
universe = relationship("Universe")
|
||||
vdcs = relationship("Vdc", back_populates="project", cascade="all, delete-orphan")
|
||||
members = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
|
||||
group_bindings = relationship("ProjectGroupBinding", back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def cloudflare_configured(self) -> bool:
|
||||
return bool(self.cloudflare_api_token_encrypted and self.cloudflare_account_id and self.cloudflare_zone_id)
|
||||
|
||||
def cloudflare_credentials(self):
|
||||
"""Decrypt and return (api_token, account_id, zone_id), or None if unset."""
|
||||
if not self.cloudflare_configured:
|
||||
return None
|
||||
from app.crypto_utils import decrypt_secret
|
||||
return decrypt_secret(self.cloudflare_api_token_encrypted), self.cloudflare_account_id, self.cloudflare_zone_id
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"kind": self.kind,
|
||||
"description": self.description,
|
||||
"universe_id": self.universe_id,
|
||||
"created_by": self.created_by,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"cloudflare_configured": self.cloudflare_configured,
|
||||
"cloudflare_account_id": self.cloudflare_account_id,
|
||||
"cloudflare_zone_id": self.cloudflare_zone_id,
|
||||
"cloudflare_domain": self.cloudflare_domain,
|
||||
"cloudflare_verified_at": self.cloudflare_verified_at.isoformat() if self.cloudflare_verified_at else None,
|
||||
}
|
||||
|
||||
|
||||
class Vdc(db.Model):
|
||||
"""A tenant unit. Its id is the tenant_id core resources are stamped with."""
|
||||
__tablename__ = "vdcs"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=_uuid)
|
||||
project_id = Column(String(36), ForeignKey("projects.id"), nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
region_id = Column(String(36), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
project = relationship("Project", back_populates="vdcs")
|
||||
members = relationship("VdcMember", back_populates="vdc", cascade="all, delete-orphan")
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"project_id": self.project_id,
|
||||
"name": self.name,
|
||||
"region_id": self.region_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
ROLE_READ = "read"
|
||||
ROLE_WRITE = "write"
|
||||
VDC_ROLES = (ROLE_READ, ROLE_WRITE)
|
||||
|
||||
|
||||
class VdcMember(db.Model):
|
||||
"""Grants a role on one VDC to one email.
|
||||
|
||||
Invited by email rather than user_id: the invited person may not have
|
||||
logged in yet. user_id is resolved and backfilled the first time that
|
||||
email authenticates (see auth_utils.upsert_user_from_claims).
|
||||
"""
|
||||
__tablename__ = "vdc_members"
|
||||
__table_args__ = (UniqueConstraint("vdc_id", "email", name="uq_vdc_member_email"),)
|
||||
|
||||
id = Column(String(36), primary_key=True, default=_uuid)
|
||||
vdc_id = Column(String(36), ForeignKey("vdcs.id"), nullable=False, index=True)
|
||||
email = Column(String(255), nullable=False, index=True)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
||||
role = Column(String(20), nullable=False, default=ROLE_READ)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
vdc = relationship("Vdc", back_populates="members")
|
||||
user = relationship("User")
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"vdc_id": self.vdc_id,
|
||||
"email": self.email,
|
||||
"user_id": self.user_id,
|
||||
"role": self.role,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class ProjectMember(db.Model):
|
||||
__tablename__ = "project_members"
|
||||
__table_args__ = (UniqueConstraint("project_id", "email", name="uq_project_member_email"),)
|
||||
|
||||
id = Column(String(36), primary_key=True, default=_uuid)
|
||||
project_id = Column(String(36), ForeignKey("projects.id"), nullable=False, index=True)
|
||||
email = Column(String(255), nullable=False, index=True)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
||||
role = Column(String(20), nullable=False, default=PROJECT_ROLE_MEMBER)
|
||||
source = Column(String(20), nullable=False, default=MEMBER_SOURCE_INVITE)
|
||||
idp_group = Column(String(255), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
project = relationship("Project", back_populates="members")
|
||||
user = relationship("User")
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"project_id": self.project_id,
|
||||
"email": self.email,
|
||||
"user_id": self.user_id,
|
||||
"role": self.role,
|
||||
"source": self.source,
|
||||
"idp_group": self.idp_group,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class ProjectGroupBinding(db.Model):
|
||||
__tablename__ = "project_group_bindings"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=_uuid)
|
||||
project_id = Column(String(36), ForeignKey("projects.id"), nullable=False, index=True)
|
||||
group_name = Column(String(255), nullable=False, unique=True)
|
||||
role = Column(String(20), nullable=False, default=PROJECT_ROLE_MEMBER)
|
||||
auto_created = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
project = relationship("Project", back_populates="group_bindings")
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"project_id": self.project_id,
|
||||
"project_name": self.project.name if self.project else None,
|
||||
"group_name": self.group_name,
|
||||
"role": self.role,
|
||||
"auto_created": self.auto_created,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
deleted = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
def delete(self, deleted_at: datetime = None):
|
||||
self.deleted_at = deleted_at or datetime.now()
|
||||
self.deleted = True
|
||||
|
||||
def restore(self):
|
||||
self.deleted_at = None
|
||||
self.deleted = False
|
||||
|
||||
soft_delete = delete
|
||||
|
||||
|
||||
class BaseModel(SoftDeleteMixin, db.Model):
|
||||
"""Core's BaseModel shape, kept identical so ported routes work unchanged."""
|
||||
__abstract__ = True
|
||||
|
||||
id = Column(String(36), primary_key=True, default=_uuid)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
visible = Column(Boolean, default=True, nullable=False)
|
||||
name = Column(String(255), nullable=False, default="")
|
||||
description = Column(LONGTEXT, nullable=True)
|
||||
status = Column(String(50), nullable=True)
|
||||
created_by = Column(String(36), ForeignKey("users.id"), nullable=True)
|
||||
|
||||
def to_json(self) -> dict:
|
||||
result = {}
|
||||
for column in self.__table__.columns:
|
||||
value = getattr(self, column.name)
|
||||
if isinstance(value, datetime):
|
||||
result[column.name] = value.isoformat()
|
||||
else:
|
||||
result[column.name] = value
|
||||
return result
|
||||
|
||||
|
||||
class CloudflareTunnel(BaseModel):
|
||||
"""A Cloudflare tunnel provisioned for one pod's public exposure.
|
||||
|
||||
Provisioned using the owning VDC's *project's* own Cloudflare credentials
|
||||
where each tenant brings their own Cloudflare account/domain.
|
||||
|
||||
"""
|
||||
__tablename__ = "cloudflare_tunnels"
|
||||
|
||||
vdc_id = Column(String(36), ForeignKey("vdcs.id"), nullable=False, index=True)
|
||||
account_id = Column(String(255), nullable=False)
|
||||
tunnel_id = Column(String(255), nullable=False, unique=True)
|
||||
tunnel_secret = Column(String(255), nullable=False)
|
||||
token = Column(String(255), nullable=False)
|
||||
associated_hostname = Column(String(255), nullable=True)
|
||||
notes = Column(LONGTEXT, nullable=True)
|
||||
nscontroller_workload_id = Column(String(36), nullable=True)
|
||||
pod_id = Column(String(36), nullable=True)
|
||||
|
||||
vdc = relationship("Vdc")
|
||||
|
||||
def to_json(self):
|
||||
data = super().to_json()
|
||||
data.pop("tunnel_secret", None)
|
||||
data.pop("token", None)
|
||||
data["dns_records_count"] = len(self.dns_records) if hasattr(self, 'dns_records') else 0
|
||||
return data
|
||||
|
||||
|
||||
class CloudflareDNSRecord(BaseModel):
|
||||
"""A DNS record pointing at a CloudflareTunnel, for one exposed container port.
|
||||
|
||||
"""
|
||||
__tablename__ = "cloudflare_dns_records"
|
||||
|
||||
zone_id = Column(String(255), nullable=False)
|
||||
dns_record_id = Column(String(255), nullable=False, unique=True)
|
||||
hostname = Column(String(255), nullable=False)
|
||||
record_type = Column(String(50), default="CNAME", nullable=False)
|
||||
content = Column(String(255), nullable=False)
|
||||
ttl = Column(Integer, default=120)
|
||||
proxied = Column(Boolean, default=True)
|
||||
notes = Column(LONGTEXT, nullable=True)
|
||||
tunnel_id = Column(String(36), ForeignKey("cloudflare_tunnels.id"), nullable=True)
|
||||
container_workload_id = Column(String(36), nullable=True)
|
||||
internal_port = Column(Integer, nullable=True)
|
||||
|
||||
tunnel = relationship("CloudflareTunnel", backref="dns_records")
|
||||
|
||||
def to_json(self):
|
||||
result = super().to_json()
|
||||
if self.tunnel:
|
||||
result["tunnel"] = {
|
||||
"id": self.tunnel.id,
|
||||
"name": self.tunnel.name,
|
||||
"tunnel_id": self.tunnel.tunnel_id,
|
||||
"associated_hostname": self.tunnel.associated_hostname,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
class SSHKey(SoftDeleteMixin, db.Model):
|
||||
"""An account-level SSH public key, injected into VMs at launch.
|
||||
|
||||
"""
|
||||
__tablename__ = "ssh_keys"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=_uuid)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=True, index=True)
|
||||
key_name = Column(String(255), nullable=False)
|
||||
public_key_data = Column(Text, nullable=False)
|
||||
key_fingerprint = Column(String(255), nullable=True)
|
||||
is_default = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"key_name": self.key_name,
|
||||
"key_fingerprint": self.key_fingerprint,
|
||||
"is_default": self.is_default,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
def to_json_with_key(self) -> dict:
|
||||
result = self.to_json()
|
||||
result["public_key_data"] = self.public_key_data
|
||||
return result
|
||||
|
||||
|
||||
class Universe(BaseModel):
|
||||
__tablename__ = "universes"
|
||||
|
||||
universe_dns_name = Column(String(255), nullable=False, default="local")
|
||||
|
||||
|
||||
class RegionAccess(db.Model):
|
||||
"""Which projects may use which core region.
|
||||
|
||||
region_id is an opaque reference to a region in core's database -- there is
|
||||
no foreign key, because regions never left core.
|
||||
"""
|
||||
__tablename__ = "region_access"
|
||||
|
||||
project_id = Column(String(36), ForeignKey("projects.id"), primary_key=True)
|
||||
region_id = Column(String(36), primary_key=True)
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {"project_id": self.project_id, "region_id": self.region_id}
|
||||
|
||||
|
||||
class AuditEntry(db.Model):
|
||||
"""Audit trail for cloud-owned objects. Core keeps its own for core objects."""
|
||||
__tablename__ = "audit_entry"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=_uuid)
|
||||
object_id = Column(String(36), nullable=True)
|
||||
object_type = Column(String(255), nullable=False)
|
||||
audit_text = Column(LONGTEXT, nullable=False)
|
||||
audit_entry_type = Column(String(50), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
||||
additional_data = Column(LONGTEXT, nullable=True)
|
||||
is_error = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
user = relationship("User", backref="audit_entries")
|
||||
|
||||
@staticmethod
|
||||
def log_event(object, action, description, user_id=None,
|
||||
additional_data=None, is_error=False):
|
||||
import json
|
||||
from flask import g, has_request_context
|
||||
from xcloudify_shared import logger
|
||||
|
||||
if object is None:
|
||||
object_type, object_id = "None", None
|
||||
else:
|
||||
object_type, object_id = type(object).__name__, object.id
|
||||
|
||||
if user_id is None and has_request_context() and getattr(g, "current_user", None):
|
||||
user_id = g.current_user.id
|
||||
|
||||
entry = AuditEntry(
|
||||
object_type=object_type,
|
||||
object_id=object_id,
|
||||
audit_entry_type=action,
|
||||
audit_text=description,
|
||||
user_id=user_id,
|
||||
additional_data=json.dumps(additional_data) if additional_data else None,
|
||||
is_error=is_error,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
logger.debug("audit: %s %s %s", object_type, action, object_id)
|
||||
return entry
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"object_id": self.object_id,
|
||||
"object_type": self.object_type,
|
||||
"audit_text": self.audit_text,
|
||||
"audit_entry_type": self.audit_entry_type,
|
||||
"user_id": self.user_id,
|
||||
"is_error": self.is_error,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
from flask import Blueprint
|
||||
|
||||
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
from app.routes import ( # noqa: E402,F401
|
||||
auth_routes, project_routes, vdc_routes, gateway_routes, admin_routes,universe_routes, region_access_routes, certificate_routes, ssh_key_routes,
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
from functools import wraps
|
||||
|
||||
from flask import g, request
|
||||
|
||||
from app import db
|
||||
from app.core_client import region_exists
|
||||
from app.models import (
|
||||
PROJECT_KIND_ORGANIZATION, PROJECT_ROLE_MEMBER, PROJECT_ROLES, Project,
|
||||
ProjectGroupBinding, ProjectMember, Vdc, VdcMember, User,
|
||||
)
|
||||
from app.routes import api_bp
|
||||
from xcloudify_shared import api_response, logger
|
||||
|
||||
|
||||
def require_platform_admin(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = getattr(g, "current_user", None)
|
||||
if user is None:
|
||||
return api_response(success=False, status=401, message="Authentication required", error_type="UNAUTHORIZED")
|
||||
if not user.is_platform_admin:
|
||||
return api_response(success=False, status=403, message="Operator access required", error_type="FORBIDDEN")
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def _vdc_json(vdc: Vdc) -> dict:
|
||||
data = vdc.to_json()
|
||||
data["member_count"] = VdcMember.query.filter_by(vdc_id=vdc.id).count()
|
||||
data["project_name"] = vdc.project.name if vdc.project else None
|
||||
return data
|
||||
|
||||
|
||||
@api_bp.route("/admin/projects", methods=["GET"])
|
||||
@require_platform_admin
|
||||
def admin_list_projects():
|
||||
"""Every project, with its owner -- not just the caller's own."""
|
||||
projects = Project.query.order_by(Project.created_at.desc()).all()
|
||||
owners = {u.id: u for u in User.query.all()}
|
||||
out = []
|
||||
for p in projects:
|
||||
data = p.to_json()
|
||||
owner = owners.get(p.created_by)
|
||||
data["owner_email"] = owner.email if owner else None
|
||||
data["vdc_count"] = Vdc.query.filter_by(project_id=p.id).count()
|
||||
out.append(data)
|
||||
return api_response(data=out)
|
||||
|
||||
|
||||
@api_bp.route("/admin/virtual_data_centers", methods=["GET"])
|
||||
@require_platform_admin
|
||||
def admin_list_vdcs():
|
||||
"""Every VDC across every project."""
|
||||
vdcs = Vdc.query.order_by(Vdc.created_at.desc()).all()
|
||||
return api_response(data=[_vdc_json(v) for v in vdcs])
|
||||
|
||||
|
||||
@api_bp.route("/admin/virtual_data_centers", methods=["POST"])
|
||||
@require_platform_admin
|
||||
def admin_create_vdc():
|
||||
"""Create a VDC in any project from the operator console.
|
||||
|
||||
The operator UI uses the legacy ``virtual_data_centers`` resource name,
|
||||
while the tenant API creates the same record at
|
||||
``/projects/<project_id>/vdcs``. Keep both entry points on the same cloud
|
||||
model; core no longer owns VDC records.
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return api_response(
|
||||
success=False,
|
||||
status=400,
|
||||
message="A JSON request body is required",
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
name = data.get("name")
|
||||
project_id = data.get("project_id")
|
||||
region_id = data.get("region_id")
|
||||
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return api_response(success=False, status=400, message="'name' is required", error_type="VALIDATION_ERROR")
|
||||
if not isinstance(project_id, str) or not project_id.strip():
|
||||
return api_response(success=False, status=400, message="'project_id' is required", error_type="VALIDATION_ERROR")
|
||||
if not isinstance(region_id, str) or not region_id.strip():
|
||||
return api_response(success=False, status=400, message="'region_id' is required", error_type="VALIDATION_ERROR")
|
||||
|
||||
name = name.strip()
|
||||
project_id = project_id.strip()
|
||||
region_id = region_id.strip()
|
||||
|
||||
if Project.query.get(project_id) is None:
|
||||
return api_response(success=False, status=404, message="Project not found", error_type="NOT_FOUND")
|
||||
if not region_exists(region_id):
|
||||
return api_response(
|
||||
success=False,
|
||||
status=404,
|
||||
message=f"Region {region_id} not found",
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
try:
|
||||
vdc = Vdc(project_id=project_id, name=name, region_id=region_id)
|
||||
db.session.add(vdc)
|
||||
db.session.commit()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
db.session.rollback()
|
||||
logger.exception("Failed to create VDC from operator console")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Failed to create VDC",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
|
||||
return api_response(data=_vdc_json(vdc), status=201, message="VDC created")
|
||||
|
||||
|
||||
@api_bp.route("/admin/users", methods=["GET"])
|
||||
@require_platform_admin
|
||||
def admin_list_users():
|
||||
"""Everyone who has logged in, so an operator can find who to grant
|
||||
operator access to (see manage.py admin:grant)."""
|
||||
users = User.query.order_by(User.created_at.desc()).all()
|
||||
return api_response(data=[u.to_json() for u in users])
|
||||
|
||||
|
||||
|
||||
@api_bp.route("/admin/project_group_bindings", methods=["GET"])
|
||||
@require_platform_admin
|
||||
def admin_list_group_bindings():
|
||||
bindings = ProjectGroupBinding.query.order_by(ProjectGroupBinding.group_name.asc()).all()
|
||||
out = []
|
||||
for b in bindings:
|
||||
data = b.to_json()
|
||||
data["member_count"] = ProjectMember.query.filter_by(
|
||||
project_id=b.project_id, idp_group=b.group_name,
|
||||
).count()
|
||||
out.append(data)
|
||||
return api_response(data=out)
|
||||
|
||||
|
||||
@api_bp.route("/admin/project_group_bindings", methods=["POST"])
|
||||
@require_platform_admin
|
||||
def admin_create_group_binding():
|
||||
data = request.get_json(force=True) or {}
|
||||
group_name = (data.get("group_name") or "").strip().lower()
|
||||
project_id = (data.get("project_id") or "").strip()
|
||||
role = (data.get("role") or PROJECT_ROLE_MEMBER).strip().lower()
|
||||
|
||||
if not group_name:
|
||||
return api_response(success=False, status=400, message="'group_name' is required", error_type="VALIDATION_ERROR")
|
||||
if role not in PROJECT_ROLES:
|
||||
return api_response(success=False, status=400,
|
||||
message=f"'role' must be one of {PROJECT_ROLES}", error_type="VALIDATION_ERROR")
|
||||
|
||||
if project_id:
|
||||
project = Project.query.get(project_id)
|
||||
if project is None:
|
||||
return api_response(success=False, status=404, message="Project not found", error_type="NOT_FOUND")
|
||||
else:
|
||||
from app.idp_projects import project_name_for_group
|
||||
|
||||
project = Project(
|
||||
name=(data.get("project_name") or "").strip() or project_name_for_group(group_name),
|
||||
kind=PROJECT_KIND_ORGANIZATION,
|
||||
description=f"Provisioned from the '{group_name}' group in your identity provider.",
|
||||
created_by=g.current_user.id,
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.flush()
|
||||
|
||||
existing = ProjectGroupBinding.query.filter_by(group_name=group_name).first()
|
||||
if existing:
|
||||
existing.project_id = project.id
|
||||
existing.role = role
|
||||
existing.auto_created = False
|
||||
db.session.commit()
|
||||
return api_response(data=existing.to_json(), message="Group binding updated")
|
||||
|
||||
binding = ProjectGroupBinding(
|
||||
project_id=project.id, group_name=group_name, role=role, auto_created=False,
|
||||
)
|
||||
db.session.add(binding)
|
||||
db.session.commit()
|
||||
logger.info("Bound IdP group %r to project %s at role %s", group_name, project.id, role)
|
||||
return api_response(data=binding.to_json(), status=201, message="Group bound to project")
|
||||
|
||||
|
||||
@api_bp.route("/admin/project_group_bindings/<binding_id>", methods=["DELETE"])
|
||||
@require_platform_admin
|
||||
def admin_delete_group_binding(binding_id):
|
||||
binding = ProjectGroupBinding.query.get(binding_id)
|
||||
if binding is None:
|
||||
return api_response(success=False, status=404, message="Binding not found", error_type="NOT_FOUND")
|
||||
|
||||
revoked = ProjectMember.query.filter_by(
|
||||
project_id=binding.project_id, idp_group=binding.group_name,
|
||||
).delete(synchronize_session=False)
|
||||
db.session.delete(binding)
|
||||
db.session.commit()
|
||||
logger.info("Unbound IdP group %r, revoking %d membership(s)", binding.group_name, revoked)
|
||||
return api_response(message=f"Group unbound; {revoked} membership(s) revoked")
|
||||
@@ -0,0 +1,23 @@
|
||||
from flask import g
|
||||
|
||||
from app.authz import describe_project, resolve_active_project
|
||||
from app.models import MEMBER_SOURCE_IDP, ProjectMember
|
||||
from app.routes import api_bp
|
||||
from xcloudify_shared import api_response
|
||||
|
||||
|
||||
@api_bp.route("/auth/me", methods=["GET"])
|
||||
def auth_me():
|
||||
"""The local user. Becomes a real session once auth lands."""
|
||||
data = g.current_user.to_json()
|
||||
|
||||
active = resolve_active_project(g.current_user)
|
||||
data["active_project"] = describe_project(active, g.current_user) if active else None
|
||||
data["active_project_id"] = active.id if active else None
|
||||
|
||||
data["idp_groups"] = sorted({
|
||||
m.idp_group for m in
|
||||
ProjectMember.query.filter_by(user_id=g.current_user.id, source=MEMBER_SOURCE_IDP).all()
|
||||
if m.idp_group
|
||||
})
|
||||
return api_response(data=data)
|
||||
@@ -0,0 +1,740 @@
|
||||
"""
|
||||
Certificate API Routes
|
||||
|
||||
This module provides API routes for managing Certificate Authorities and Certificates.
|
||||
|
||||
NOT YET GATED by app.authz: covered by the global before_request auth check
|
||||
(app/__init__.py), so a request needs a valid session -- but nothing here
|
||||
checks that session belongs to the project being read or written, the way
|
||||
project_routes.py's cloudflare endpoints check via require_project_owner.
|
||||
Several routes here take a ca_id/cert_id with no project_id in the URL, so
|
||||
fixing this means resolving project ownership from the CA/cert row first
|
||||
rather than a mechanical decorator swap. Left as a known gap alongside the
|
||||
OAuth rollout rather than rushed.
|
||||
"""
|
||||
|
||||
from flask import request, current_app
|
||||
from app import db
|
||||
from xcloudify_shared import logger
|
||||
from app.certificate_models import CertificateAuthority, Certificate, CertificateRevocationList
|
||||
from app.models import Project, AuditEntry
|
||||
from cryptography import x509
|
||||
from app.utils.certificate_utils import (
|
||||
generate_self_signed_ca,
|
||||
issue_certificate,
|
||||
generateCRL,
|
||||
encrypt_private_key,
|
||||
decrypt_private_key
|
||||
)
|
||||
from app.routes import api_bp
|
||||
from xcloudify_shared import api_response
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
from app.auth_utils import get_request_user_id
|
||||
|
||||
|
||||
@api_bp.route('/certificates/ca/project/<project_id>', methods=['GET'])
|
||||
def get_ca_for_project(project_id):
|
||||
"""
|
||||
Get the CA for a project if it exists.
|
||||
|
||||
Args:
|
||||
project_id (str): ID of the project
|
||||
|
||||
Returns:
|
||||
JSON response with CA details or empty response if no CA exists
|
||||
"""
|
||||
try:
|
||||
# Check if project exists
|
||||
project = Project.query.get_or_404(project_id)
|
||||
|
||||
# Check if CA already exists for this project
|
||||
ca = CertificateAuthority.query.filter_by(project_id=project_id, deleted=False).first()
|
||||
|
||||
if not ca:
|
||||
return api_response(
|
||||
success=False,
|
||||
status=404,
|
||||
message="No Certificate Authority found for this project",
|
||||
error_type="NOT_FOUND"
|
||||
)
|
||||
|
||||
return api_response(data=ca.to_json(), message="CA retrieved successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting CA for project {project_id}: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route('/certificates/ca/project/<project_id>', methods=['POST'])
|
||||
def create_ca_for_project(project_id):
|
||||
"""
|
||||
Create a new CA for a project.
|
||||
|
||||
Args:
|
||||
project_id (str): ID of the project
|
||||
|
||||
Returns:
|
||||
JSON response with CA details
|
||||
"""
|
||||
try:
|
||||
# Check if project exists
|
||||
project = Project.query.get_or_404(project_id)
|
||||
|
||||
# Check if CA already exists for this project
|
||||
ca = CertificateAuthority.query.filter_by(project_id=project_id, deleted=False).first()
|
||||
|
||||
if ca:
|
||||
return api_response(
|
||||
success=False,
|
||||
status=409,
|
||||
message="Certificate Authority already exists for this project",
|
||||
error_type="CONFLICT"
|
||||
)
|
||||
|
||||
# Create a new CA
|
||||
domain_name = f"{project_id}.{current_app.config['BASE_DOMAIN']}"
|
||||
common_name = f"rootca.{domain_name}"
|
||||
new_ca_id = uuid.uuid4()
|
||||
crl_url = f"{current_app.config['API_BASE_URL']}/certificates/crl/{new_ca_id}"
|
||||
|
||||
ca_data = generate_self_signed_ca(
|
||||
common_name=common_name,
|
||||
organization="xCloudify",
|
||||
organizational_unit="IT",
|
||||
validity_years=5,
|
||||
crl_url=crl_url
|
||||
)
|
||||
|
||||
# Encrypt the private key
|
||||
encrypted_private_key = encrypt_private_key(ca_data["private_key"], project_id)
|
||||
|
||||
ca = CertificateAuthority(
|
||||
id=new_ca_id,
|
||||
name="",
|
||||
project_id=project_id,
|
||||
common_name=common_name,
|
||||
organization="xCloudify",
|
||||
organizational_unit="IT",
|
||||
domain_name=domain_name,
|
||||
validity_period=5,
|
||||
is_active=True,
|
||||
private_key=encrypted_private_key, # Store encrypted private key
|
||||
certificate_data=ca_data["certificate"], # Store certificate
|
||||
serial_number=ca_data["serial_number"]
|
||||
)
|
||||
|
||||
db.session.add(ca)
|
||||
db.session.commit()
|
||||
|
||||
generateCRL(ca)
|
||||
|
||||
logger.info(f"Created new CA for project {project_id} with ID:{new_ca_id}")
|
||||
# Audit
|
||||
try:
|
||||
user_id = get_request_user_id()
|
||||
AuditEntry.log_event(
|
||||
object=ca,
|
||||
action="ca_created",
|
||||
description=f"project_id={project_id} common_name={common_name}",
|
||||
user_id=user_id
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Audit logging failed for CA create {new_ca_id}: {exc}")
|
||||
|
||||
return api_response(
|
||||
data=ca.to_json(),
|
||||
status=201,
|
||||
message="CA created successfully"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating CA for project {project_id}: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route('/certificates/ca/<ca_id>', methods=['GET'])
|
||||
def get_ca_details(ca_id):
|
||||
"""
|
||||
Get detailed information about a specific CA.
|
||||
|
||||
Args:
|
||||
ca_id (str): ID of the CA
|
||||
|
||||
Returns:
|
||||
JSON response with CA details
|
||||
"""
|
||||
try:
|
||||
ca = CertificateAuthority.query.get_or_404(ca_id)
|
||||
return api_response(data=ca.to_json())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting CA details for {ca_id}: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route('/certificates/ca/<ca_id>/download', methods=['GET'])
|
||||
def download_ca(ca_id):
|
||||
"""
|
||||
Download a ca's public or private key.
|
||||
|
||||
Args:
|
||||
cert_id (str): ID of the certificate
|
||||
|
||||
Returns:
|
||||
PEM-formatted certificate data
|
||||
"""
|
||||
try:
|
||||
|
||||
# Download CA
|
||||
ca = CertificateAuthority.query.get_or_404(ca_id)
|
||||
cert_type = request.args.get('type', 'certificate') # default to certificate
|
||||
|
||||
if cert_type == 'certificate':
|
||||
# Return CA ca
|
||||
headers = {
|
||||
'Content-Type': 'application/x-pem-file',
|
||||
'Content-Disposition': f'attachment; filename="{ca_id}-ca.crt"'
|
||||
}
|
||||
return ca.certificate_data, 200, headers
|
||||
elif cert_type == 'private_key':
|
||||
# Return CA private key (in a real implementation, this would require additional authentication)
|
||||
# For this example, we'll decrypt and return it
|
||||
decrypted_private_key = decrypt_private_key(ca.private_key, ca.project_id)
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/x-pem-file',
|
||||
'Content-Disposition': f'attachment; filename="{ca_id}-key.pem"'
|
||||
}
|
||||
return decrypted_private_key, 200, headers
|
||||
else:
|
||||
return api_response(
|
||||
success=False,
|
||||
status=400,
|
||||
message="Invalid type parameter for CA",
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details={"errors": ["type must be 'ca' or 'private_key' for CA"]}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading ca {ca_id}: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route('/certificates/ca/<ca_id>', methods=['PUT'])
|
||||
def update_ca(ca_id):
|
||||
"""
|
||||
Update CA information.
|
||||
|
||||
Args:
|
||||
ca_id (str): ID of the CA
|
||||
|
||||
Returns:
|
||||
JSON response confirming update
|
||||
"""
|
||||
try:
|
||||
ca = CertificateAuthority.query.get_or_404(ca_id)
|
||||
data = request.json
|
||||
|
||||
# Update allowed fields
|
||||
if 'common_name' in data:
|
||||
ca.common_name = data['common_name']
|
||||
if 'country' in data:
|
||||
ca.country = data['country']
|
||||
if 'state' in data:
|
||||
ca.state = data['state']
|
||||
if 'city' in data:
|
||||
ca.city = data['city']
|
||||
if 'organization' in data:
|
||||
ca.organization = data['organization']
|
||||
if 'organizational_unit' in data:
|
||||
ca.organizational_unit = data['organizational_unit']
|
||||
if 'is_active' in data:
|
||||
ca.is_active = data['is_active']
|
||||
|
||||
ca.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
# Audit
|
||||
try:
|
||||
user_id = get_request_user_id()
|
||||
updated_fields = list(data.keys()) if isinstance(data, dict) else []
|
||||
fields = ", ".join(updated_fields)
|
||||
AuditEntry.log_event(
|
||||
object=ca,
|
||||
action="ca_updated",
|
||||
description=f"Updated fields: {fields}" if fields else "Updated",
|
||||
user_id=user_id
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Audit logging failed for CA update {ca_id}: {exc}")
|
||||
|
||||
return api_response(message="CA updated successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating CA {ca_id}: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route('/certificates/issue', methods=['POST'])
|
||||
def create_certificate():
|
||||
"""
|
||||
Issue a new certificate from a CA.
|
||||
|
||||
Returns:
|
||||
JSON response with certificate details
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ['ca_id', 'certificate_type', 'common_name','name']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return api_response(
|
||||
success=False,
|
||||
status=400,
|
||||
message="Validation error",
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details={"errors": [f"Missing required field: {field}"]}
|
||||
)
|
||||
|
||||
# Get CA
|
||||
ca: CertificateAuthority = CertificateAuthority.query.get_or_404(data['ca_id'])
|
||||
|
||||
# Decrypt CA private key (in a real implementation, this would require additional authentication)
|
||||
# For this example, we'll use the project_id as the password
|
||||
decrypted_ca_private_key = decrypt_private_key(ca.private_key, ca.project_id)
|
||||
|
||||
#Just in case somsone specifies the full domain name, lets strip it all off
|
||||
data['common_name']=str(data['common_name']).replace(ca.domain_name,"")
|
||||
full_common_name=f"{data['common_name']}.{ca.domain_name}"
|
||||
# Also check for double periods and replace with a single period
|
||||
full_common_name=str(full_common_name).replace("..",".")
|
||||
|
||||
|
||||
# Issue certificate
|
||||
cert_data = issue_certificate(
|
||||
ca_private_key_pem=decrypted_ca_private_key,
|
||||
ca_cert_pem=ca.certificate_data,
|
||||
common_name=full_common_name,
|
||||
certificate_type=data['certificate_type'],
|
||||
country=data.get('country'),
|
||||
state=data.get('state'),
|
||||
city=data.get('city'),
|
||||
organization=data.get('organization'),
|
||||
organizational_unit=data.get('organizational_unit'),
|
||||
email=data.get('email'),
|
||||
validity_years=data.get('validity_period', 1)
|
||||
)
|
||||
|
||||
# Encrypt the private key
|
||||
encrypted_private_key = encrypt_private_key(cert_data["private_key"], ca.project_id)
|
||||
|
||||
# Create certificate record
|
||||
cert = Certificate(
|
||||
name=data['name'],
|
||||
ca_id=data['ca_id'],
|
||||
certificate_type=data['certificate_type'],
|
||||
common_name=full_common_name,
|
||||
country=data.get('country'),
|
||||
state=data.get('state'),
|
||||
city=data.get('city'),
|
||||
organization=data.get('organization'),
|
||||
organizational_unit=data.get('organizational_unit'),
|
||||
email=data.get('email'),
|
||||
validity_period=data.get('validity_period', 1),
|
||||
is_active=True,
|
||||
revoked=False,
|
||||
public_key=cert_data["public_key"],
|
||||
private_key=encrypted_private_key,
|
||||
certificate_data=cert_data["certificate"],
|
||||
serial_number=cert_data["serial_number"]
|
||||
)
|
||||
|
||||
db.session.add(cert)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Created new certificate {cert.id} for CA {ca.id}")
|
||||
# Audit
|
||||
try:
|
||||
user_id = get_request_user_id()
|
||||
AuditEntry.log_event(
|
||||
object=cert,
|
||||
action="certificate_issued",
|
||||
description=f"name={data.get('name')} common_name={full_common_name} ca_id={ca.id}",
|
||||
user_id=user_id
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Audit logging failed for certificate issue {cert.id}: {exc}")
|
||||
|
||||
return api_response(
|
||||
data=cert.to_json(),
|
||||
status=201,
|
||||
message="Certificate created successfully"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating certificate: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route('/certificates/cert/ca/<ca_id>', methods=['GET'])
|
||||
def list_certificates_for_ca(ca_id):
|
||||
"""
|
||||
List all certificates issued by a specific CA.
|
||||
|
||||
Args:
|
||||
ca_id (str): ID of the CA
|
||||
|
||||
Returns:
|
||||
JSON response with list of certificates
|
||||
"""
|
||||
try:
|
||||
# Check if CA exists
|
||||
ca = CertificateAuthority.query.get_or_404(ca_id)
|
||||
|
||||
|
||||
# Get certificates for this CA
|
||||
certificates = Certificate.query.filter_by(ca_id=ca_id, deleted=False).all()
|
||||
return api_response(
|
||||
data=[cert.to_json() for cert in certificates],
|
||||
message=f"Found {len(certificates)} certificates for CA {ca_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing certificates for CA {ca_id}: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route('/certificates/cert/<cert_id>', methods=['GET'])
|
||||
def get_certificate_details(cert_id):
|
||||
"""
|
||||
Get detailed information about a specific certificate.
|
||||
|
||||
Args:
|
||||
cert_id (str): ID of the certificate
|
||||
|
||||
Returns:
|
||||
JSON response with certificate details
|
||||
"""
|
||||
try:
|
||||
cert = Certificate.query.get_or_404(cert_id)
|
||||
return api_response(data=cert.to_json())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting certificate details for {cert_id}: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route('/certificates/cert/<cert_id>', methods=['PUT'])
|
||||
def update_certificate(cert_id):
|
||||
"""
|
||||
Update certificate information.
|
||||
|
||||
Args:
|
||||
cert_id (str): ID of the certificate
|
||||
|
||||
Returns:
|
||||
JSON response confirming update
|
||||
"""
|
||||
try:
|
||||
cert = Certificate.query.get_or_404(cert_id)
|
||||
data = request.json
|
||||
|
||||
# Update allowed fields
|
||||
if 'is_active' in data:
|
||||
cert.is_active = data['is_active']
|
||||
|
||||
cert.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
# Audit
|
||||
try:
|
||||
user_id = get_request_user_id()
|
||||
updated_fields = list(data.keys()) if isinstance(data, dict) else []
|
||||
fields = ", ".join(updated_fields)
|
||||
AuditEntry.log_event(
|
||||
object=cert,
|
||||
action="certificate_updated",
|
||||
description=f"Updated fields: {fields}" if fields else "Updated",
|
||||
user_id=user_id
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Audit logging failed for certificate update {cert_id}: {exc}")
|
||||
|
||||
return api_response(message="Certificate updated successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating certificate {cert_id}: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route('/certificates/cert/<cert_id>/revoke', methods=['POST'])
|
||||
def revoke_certificate_route(cert_id):
|
||||
"""
|
||||
Revoke a certificate.
|
||||
|
||||
Args:
|
||||
cert_id (str): ID of the certificate
|
||||
|
||||
Returns:
|
||||
JSON response confirming revocation
|
||||
"""
|
||||
try:
|
||||
cert: Certificate = Certificate.query.get_or_404(cert_id)
|
||||
|
||||
# Check if certificate is already revoked
|
||||
if cert.revoked:
|
||||
return api_response(
|
||||
success=False,
|
||||
status=400,
|
||||
message="Certificate already revoked",
|
||||
error_type="VALIDATION_ERROR"
|
||||
)
|
||||
|
||||
# Update certificate status
|
||||
cert.revoked = True
|
||||
cert.revoked_at = datetime.utcnow()
|
||||
logger.debug("attempting to revoke")
|
||||
generateCRL(cert.ca)
|
||||
|
||||
logger.info(f"Revoked certificate {cert_id} and updated CRL for CA {cert.ca.id}")
|
||||
# Audit (log_event commits session changes, including revoke flags)
|
||||
try:
|
||||
user_id = get_request_user_id()
|
||||
AuditEntry.log_event(
|
||||
object=cert,
|
||||
action="certificate_revoked",
|
||||
description=f"cert_id={cert_id} ca_id={cert.ca.id}",
|
||||
user_id=user_id
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Audit logging failed for certificate revoke {cert_id}: {exc}")
|
||||
|
||||
return api_response(message="Certificate revoked successfully and CRL updated",success=True,status=200)
|
||||
except Exception as e:
|
||||
logger.error(f"Error revoking certificate {cert_id}: {str(e)}")
|
||||
db.session.rollback()
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route('/certificates/cert/<cert_id>/download', methods=['GET'])
|
||||
def download_certificate(cert_id):
|
||||
"""
|
||||
Download a certificate's public or private key.
|
||||
|
||||
Args:
|
||||
cert_id (str): ID of the certificate
|
||||
|
||||
Returns:
|
||||
PEM-formatted certificate data
|
||||
"""
|
||||
try:
|
||||
# Check if we're downloading a CA certificate
|
||||
|
||||
|
||||
# Download regular certificate
|
||||
cert = Certificate.query.get_or_404(cert_id)
|
||||
cert_type = request.args.get('type', 'certificate') # default to certificate
|
||||
|
||||
if cert_type == 'certificate':
|
||||
# Return certificate
|
||||
headers = {
|
||||
'Content-Type': 'application/x-pem-file',
|
||||
'Content-Disposition': f'attachment; filename="{cert.id}-cert.crt"'
|
||||
}
|
||||
return cert.certificate_data, 200, headers
|
||||
elif cert_type == 'private_key':
|
||||
# Return private key (in a real implementation, this would require additional authentication)
|
||||
# For this example, we'll decrypt and return it
|
||||
decrypted_private_key = decrypt_private_key(cert.private_key, cert.ca.project_id)
|
||||
headers = {
|
||||
'Content-Type': 'application/x-pem-file',
|
||||
'Content-Disposition': f'attachment; filename="{cert.id}-key.pem"'
|
||||
}
|
||||
return decrypted_private_key, 200, headers
|
||||
elif cert_type == 'public_key':
|
||||
# Return public key
|
||||
headers = {
|
||||
'Content-Type': 'application/x-pem-file',
|
||||
'Content-Disposition': f'attachment; filename="{cert.id}-pub.pem"'
|
||||
}
|
||||
return cert.public_key, 200, headers
|
||||
else:
|
||||
return api_response(
|
||||
success=False,
|
||||
status=400,
|
||||
message="Invalid type parameter",
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details={"errors": ["type must be 'certificate', 'private_key', or 'public_key'"]}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading certificate {cert_id}: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route('/certificates/ca', methods=['GET'])
|
||||
def list_all_cas():
|
||||
"""
|
||||
Get all Certificate Authorities.
|
||||
|
||||
Returns:
|
||||
JSON response with list of all CAs
|
||||
"""
|
||||
try:
|
||||
# Get all CAs
|
||||
cas = CertificateAuthority.query.filter_by(deleted=False).all()
|
||||
|
||||
return api_response(
|
||||
data=[ca.to_json() for ca in cas],
|
||||
message=f"Found {len(cas)} Certificate Authorities"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing all CAs: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route('/certificates/cert/project/<project_id>', methods=['GET'])
|
||||
def list_certificates_for_project(project_id):
|
||||
"""
|
||||
List all certificates for a specific project.
|
||||
|
||||
Args:
|
||||
project_id (str): ID of the project
|
||||
|
||||
Returns:
|
||||
JSON response with list of certificates for the project
|
||||
"""
|
||||
try:
|
||||
# Check if project exists
|
||||
project = Project.query.get_or_404(project_id)
|
||||
|
||||
# Get CA for this project
|
||||
ca = CertificateAuthority.query.filter_by(project_id=project_id, deleted=False).first()
|
||||
if not ca:
|
||||
return api_response(
|
||||
success=False,
|
||||
status=404,
|
||||
message="No Certificate Authority found for this project",
|
||||
error_type="NOT_FOUND"
|
||||
)
|
||||
|
||||
# Get certificates for this CA
|
||||
certificates = Certificate.query.filter_by(ca_id=ca.id, deleted=False).all()
|
||||
return api_response(
|
||||
data=[cert.to_json() for cert in certificates],
|
||||
message=f"Found {len(certificates)} certificates for project {project_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing certificates for project {project_id}: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
|
||||
@api_bp.route('/certificates/crl/<ca_id>', methods=['GET'])
|
||||
def get_crlist(ca_id):
|
||||
"""
|
||||
Get the current CRL for a CA.
|
||||
|
||||
Args:
|
||||
ca_id (str): ID of the CA
|
||||
|
||||
Returns:
|
||||
PEM-formatted CRL data
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Attempting to get CA {ca_id}")
|
||||
ca: CertificateAuthority = CertificateAuthority.query.get_or_404(ca_id)
|
||||
|
||||
# Get the current CRL for this CA
|
||||
if ca.current_crl:
|
||||
crl_data = ca.current_crl.crl_data
|
||||
else:
|
||||
# If no CRL exists, return a standardized 404 JSON response
|
||||
return api_response(
|
||||
success=False,
|
||||
status=404,
|
||||
message="No CRL for this CA",
|
||||
error_type="NO_CRL"
|
||||
)
|
||||
headers = {
|
||||
'Content-Type': 'application/x-pem-file',
|
||||
'Content-Disposition': f'attachment; filename="{ca_id}-CRL.pem"'
|
||||
}
|
||||
return crl_data, 200, headers
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting CRL for CA {ca_id}: {str(e)}")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Internal server error",
|
||||
error_type="INTERNAL_ERROR",
|
||||
error_details={"detail": str(e)}
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
import json
|
||||
|
||||
from flask import Response, g, request
|
||||
|
||||
from app.authz import require_auth, vdc_role
|
||||
from app.core_client import core_request
|
||||
from app.models import ROLE_WRITE, SSHKey, Vdc
|
||||
from app.routes import api_bp
|
||||
from xcloudify_shared import api_response
|
||||
from xcloudify_shared.gateway import WORKLOAD_ROUTES, deleted_workload, pending_dns_exposures
|
||||
from xcloudify_shared.ssh_keys import resolve_ssh_keys
|
||||
|
||||
|
||||
@api_bp.route("/vdcs/<vdc_id>/core/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE"])
|
||||
@require_auth
|
||||
def gateway(vdc_id, subpath):
|
||||
needs_write = WORKLOAD_ROUTES.needs_write(request.method, subpath)
|
||||
if needs_write is None:
|
||||
return api_response(
|
||||
success=False, status=404,
|
||||
message=f"{request.method} {subpath} is not exposed through the VDC gateway",
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
role = vdc_role(g.current_user, vdc_id)
|
||||
if role is None:
|
||||
return api_response(success=False, status=404, message="VDC not found", error_type="NOT_FOUND")
|
||||
if needs_write and role != ROLE_WRITE:
|
||||
return api_response(success=False, status=403, message="Read-only access to this VDC", error_type="FORBIDDEN")
|
||||
|
||||
vdc = Vdc.query.get(vdc_id)
|
||||
|
||||
params = request.args.to_dict()
|
||||
params["tenant_id"] = vdc.id
|
||||
params["region_id"] = vdc.region_id
|
||||
|
||||
json_body = None
|
||||
if request.method in ("POST", "PUT"):
|
||||
json_body = request.get_json(silent=True) or {}
|
||||
json_body["tenant_id"] = vdc.id
|
||||
json_body["region_id"] = vdc.region_id
|
||||
if subpath == "workloads/virtual_machines":
|
||||
resolve_ssh_keys(json_body, SSHKey, g.current_user.id)
|
||||
|
||||
resp = core_request(request.method, subpath, params=params, json=json_body)
|
||||
|
||||
if resp.ok:
|
||||
_maybe_trigger_dns_exposure(vdc, subpath, request.method, json_body, resp)
|
||||
_maybe_trigger_dns_cleanup(vdc, subpath, request.method)
|
||||
|
||||
content = resp.content
|
||||
if resp.ok and role != ROLE_WRITE and subpath.startswith("workloads/virtual_machines"):
|
||||
content = _without_console_tickets(content)
|
||||
|
||||
return Response(
|
||||
content,
|
||||
status=resp.status_code,
|
||||
content_type=resp.headers.get("Content-Type", "application/json"),
|
||||
)
|
||||
|
||||
|
||||
def _without_console_tickets(content: bytes) -> bytes:
|
||||
"""Drop `vnc_token` from a VM response for a read-only caller.
|
||||
|
||||
A console ticket is keyboard and mouse on the VM, which is write access
|
||||
whatever the VDC role says -- and core cannot tell the difference, since it
|
||||
hands a ticket to anyone holding its API key. This gateway is where "who
|
||||
may have one" is decided.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except ValueError:
|
||||
return content
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
for vm in (data if isinstance(data, list) else [data]):
|
||||
if isinstance(vm, dict):
|
||||
vm.pop("vnc_token", None)
|
||||
return json.dumps(payload).encode()
|
||||
|
||||
|
||||
def _maybe_trigger_dns_exposure(vdc, subpath, method, json_body, resp):
|
||||
"""Hand any use_dns port off to the cloud-side Cloudflare orchestration."""
|
||||
pending = pending_dns_exposures(subpath, method, json_body, resp)
|
||||
if pending is None:
|
||||
return
|
||||
pod_id, exposures = pending
|
||||
from app.tasks.dns_exposure import provision_exposure
|
||||
provision_exposure.delay(vdc.id, pod_id, exposures)
|
||||
|
||||
|
||||
def _maybe_trigger_dns_cleanup(vdc, subpath, method):
|
||||
"""Clean up any Cloudflare exposure that pointed at a deleted container/pod."""
|
||||
deleted = deleted_workload(subpath, method)
|
||||
if deleted is None:
|
||||
return
|
||||
kind, obj_id = deleted
|
||||
from app.tasks.dns_exposure import cleanup_exposure, cleanup_exposure_for_pod
|
||||
(cleanup_exposure if kind == "container" else cleanup_exposure_for_pod).delay(obj_id)
|
||||
@@ -0,0 +1,227 @@
|
||||
from datetime import datetime
|
||||
|
||||
from flask import g, request
|
||||
|
||||
from app import db
|
||||
from app.authz import (
|
||||
describe_project, ensure_default_project, project_role, require_auth,
|
||||
require_project_owner, require_project_role, resolve_active_project,
|
||||
visible_projects,
|
||||
)
|
||||
from app.models import (
|
||||
MEMBER_SOURCE_IDP, MEMBER_SOURCE_INVITE, PROJECT_KIND_SHARED,
|
||||
PROJECT_ROLE_MEMBER, PROJECT_ROLES, Project, ProjectMember, User,
|
||||
)
|
||||
from app.routes import api_bp
|
||||
from xcloudify_shared import api_response, logger
|
||||
|
||||
|
||||
@api_bp.route("/projects", methods=["GET"])
|
||||
@require_auth
|
||||
def list_projects():
|
||||
"""The projects this user can see. Auto-provisions one on first call if
|
||||
they have none at all, so a brand-new user always lands somewhere."""
|
||||
projects = visible_projects(g.current_user)
|
||||
if not projects:
|
||||
projects = [ensure_default_project(g.current_user)]
|
||||
active = resolve_active_project(g.current_user)
|
||||
active_id = active.id if active else None
|
||||
|
||||
out = []
|
||||
for project in projects:
|
||||
data = describe_project(project, g.current_user)
|
||||
data["is_active"] = project.id == active_id
|
||||
out.append(data)
|
||||
return api_response(data=out)
|
||||
|
||||
|
||||
@api_bp.route("/projects/active", methods=["GET"])
|
||||
@require_auth
|
||||
def get_active_project():
|
||||
project = resolve_active_project(g.current_user)
|
||||
if project is None:
|
||||
return api_response(success=False, status=404, message="No project available", error_type="NOT_FOUND")
|
||||
return api_response(data=describe_project(project, g.current_user))
|
||||
|
||||
|
||||
@api_bp.route("/projects/<project_id>/activate", methods=["POST"])
|
||||
@require_project_role("viewer")
|
||||
def activate_project(project_id):
|
||||
g.current_user.active_project_id = project_id
|
||||
db.session.commit()
|
||||
project = Project.query.get(project_id)
|
||||
return api_response(data=describe_project(project, g.current_user),
|
||||
message=f"Now working in {project.name}")
|
||||
|
||||
|
||||
@api_bp.route("/projects/<project_id>", methods=["GET"])
|
||||
@require_project_role("viewer")
|
||||
def get_project(project_id):
|
||||
return api_response(data=describe_project(Project.query.get(project_id), g.current_user))
|
||||
|
||||
|
||||
@api_bp.route("/projects", methods=["POST"])
|
||||
@require_auth
|
||||
def create_project():
|
||||
if not g.current_user.is_platform_admin:
|
||||
return api_response(
|
||||
success=False, status=403,
|
||||
message=(
|
||||
"Projects can't be created from here. Your own project is created with your "
|
||||
"account; others are shared with you or come from your identity provider groups."
|
||||
),
|
||||
error_type="FORBIDDEN",
|
||||
)
|
||||
data = request.get_json(force=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return api_response(success=False, status=400, message="'name' is required", error_type="VALIDATION_ERROR")
|
||||
|
||||
owner_email = (data.get("owner_email") or "").strip().lower()
|
||||
owner = User.query.filter_by(email=owner_email).first() if owner_email else None
|
||||
if owner_email and owner is None:
|
||||
return api_response(success=False, status=404,
|
||||
message=f"No account for {owner_email}", error_type="NOT_FOUND")
|
||||
|
||||
project = Project(
|
||||
name=name,
|
||||
description=(data.get("description") or "").strip() or None,
|
||||
kind=PROJECT_KIND_SHARED,
|
||||
created_by=(owner or g.current_user).id,
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
return api_response(data=describe_project(project, g.current_user), status=201, message="Project created")
|
||||
|
||||
|
||||
@api_bp.route("/projects/<project_id>/members", methods=["GET"])
|
||||
@require_project_role("viewer")
|
||||
def list_project_members(project_id):
|
||||
members = ProjectMember.query.filter_by(project_id=project_id).all()
|
||||
return api_response(data=[m.to_json() for m in members])
|
||||
|
||||
|
||||
@api_bp.route("/projects/<project_id>/members", methods=["POST"])
|
||||
@require_project_owner()
|
||||
def add_project_member(project_id):
|
||||
data = request.get_json(force=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
role = (data.get("role") or PROJECT_ROLE_MEMBER).strip().lower()
|
||||
if not email:
|
||||
return api_response(success=False, status=400, message="'email' is required", error_type="VALIDATION_ERROR")
|
||||
if role not in PROJECT_ROLES:
|
||||
return api_response(success=False, status=400,
|
||||
message=f"'role' must be one of {PROJECT_ROLES}", error_type="VALIDATION_ERROR")
|
||||
|
||||
existing = ProjectMember.query.filter_by(project_id=project_id, email=email).first()
|
||||
if existing:
|
||||
existing.role = role
|
||||
existing.source = MEMBER_SOURCE_INVITE
|
||||
db.session.commit()
|
||||
return api_response(data=existing.to_json(), message="Member role updated")
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
member = ProjectMember(
|
||||
project_id=project_id, email=email, role=role,
|
||||
source=MEMBER_SOURCE_INVITE, user_id=user.id if user else None,
|
||||
)
|
||||
db.session.add(member)
|
||||
db.session.commit()
|
||||
return api_response(data=member.to_json(), status=201, message="Member invited")
|
||||
|
||||
|
||||
@api_bp.route("/projects/<project_id>/members/<member_id>", methods=["DELETE"])
|
||||
@require_project_owner()
|
||||
def remove_project_member(project_id, member_id):
|
||||
member = ProjectMember.query.filter_by(id=member_id, project_id=project_id).first()
|
||||
if member is None:
|
||||
return api_response(success=False, status=404, message="Member not found", error_type="NOT_FOUND")
|
||||
if member.source == MEMBER_SOURCE_IDP:
|
||||
return api_response(
|
||||
success=False, status=409,
|
||||
message=(
|
||||
f"This access comes from the '{member.idp_group}' group in your identity provider "
|
||||
"and would be restored at their next sign-in. Remove them from the group instead."
|
||||
),
|
||||
error_type="CONFLICT",
|
||||
)
|
||||
db.session.delete(member)
|
||||
db.session.commit()
|
||||
return api_response(message="Member removed")
|
||||
|
||||
|
||||
@api_bp.route("/projects/<project_id>/cloudflare", methods=["GET"])
|
||||
@require_project_owner()
|
||||
def get_project_cloudflare(project_id):
|
||||
project = Project.query.get(project_id)
|
||||
return api_response(data=project.to_json())
|
||||
|
||||
|
||||
@api_bp.route("/projects/<project_id>/cloudflare", methods=["PUT"])
|
||||
@require_project_owner()
|
||||
def set_project_cloudflare(project_id):
|
||||
"""Register or update this project's own Cloudflare account.
|
||||
|
||||
Every project brings its own Cloudflare token/account/domain -- there is
|
||||
no platform-wide Cloudflare config. The token is verified against
|
||||
Cloudflare's API before being saved, and encrypted at rest.
|
||||
"""
|
||||
data = request.get_json(force=True) or {}
|
||||
api_token = (data.get("api_token") or "").strip()
|
||||
account_id = (data.get("account_id") or "").strip()
|
||||
zone_id = (data.get("zone_id") or "").strip()
|
||||
|
||||
missing = [f for f, v in (("api_token", api_token), ("account_id", account_id), ("zone_id", zone_id)) if not v]
|
||||
if missing:
|
||||
return api_response(
|
||||
success=False, status=400,
|
||||
message=f"Missing required field(s): {', '.join(missing)}",
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
from xcloudify_shared.cloudflare import CloudflareTunnelManager
|
||||
cf_mgr = CloudflareTunnelManager(api_token, account_id, zone_id, logger)
|
||||
try:
|
||||
domain = cf_mgr.verify_credentials()
|
||||
except Exception as exc:
|
||||
return api_response(
|
||||
success=False, status=400,
|
||||
message=f"Could not verify Cloudflare credentials: {exc}",
|
||||
error_type="CLOUDFLARE_VERIFICATION_FAILED",
|
||||
)
|
||||
|
||||
try:
|
||||
from app.crypto_utils import encrypt_secret
|
||||
encrypted_token = encrypt_secret(api_token)
|
||||
except Exception as exc:
|
||||
logger.exception("Could not encrypt Cloudflare token for project %s", project_id)
|
||||
return api_response(
|
||||
success=False, status=500,
|
||||
message=f"Server is not configured to store secrets: {exc}",
|
||||
error_type="SECRET_STORAGE_UNAVAILABLE",
|
||||
)
|
||||
|
||||
project = Project.query.get(project_id)
|
||||
project.cloudflare_api_token_encrypted = encrypted_token
|
||||
project.cloudflare_account_id = account_id
|
||||
project.cloudflare_zone_id = zone_id
|
||||
project.cloudflare_domain = domain
|
||||
project.cloudflare_verified_at = datetime.utcnow()
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data=project.to_json(), message="Cloudflare credentials verified and saved")
|
||||
|
||||
|
||||
@api_bp.route("/projects/<project_id>/cloudflare", methods=["DELETE"])
|
||||
@require_project_owner()
|
||||
def delete_project_cloudflare(project_id):
|
||||
project = Project.query.get(project_id)
|
||||
project.cloudflare_api_token_encrypted = None
|
||||
project.cloudflare_account_id = None
|
||||
project.cloudflare_zone_id = None
|
||||
project.cloudflare_domain = None
|
||||
project.cloudflare_verified_at = None
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
return api_response(data=project.to_json(), message="Cloudflare credentials removed")
|
||||
@@ -0,0 +1,202 @@
|
||||
import uuid
|
||||
from flask import request
|
||||
from xcloudify_shared import api_response
|
||||
from uuid import UUID
|
||||
from app import db
|
||||
from xcloudify_shared import logger
|
||||
from app.models import RegionAccess, Project, AuditEntry
|
||||
from app.core_client import region_exists
|
||||
from app.routes import api_bp
|
||||
from app.auth_utils import get_request_user_id
|
||||
from app.routes.admin_routes import require_platform_admin
|
||||
|
||||
# Helper function to validate UUID
|
||||
def is_valid_uuid(uuid_to_test, version=4):
|
||||
try:
|
||||
UUID(uuid_to_test, version=version)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# RegionAccess Routes. Which projects may use which core region is a
|
||||
# platform-level allocation decision, not something a tenant manages for
|
||||
# themselves -- gated on is_platform_admin throughout.
|
||||
@api_bp.route('/region_access', methods=['POST'])
|
||||
@require_platform_admin
|
||||
def add_region_access():
|
||||
data = request.json
|
||||
|
||||
# Validate incoming data
|
||||
if not data:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="No data provided",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR"
|
||||
)
|
||||
|
||||
# Check if required fields are present
|
||||
required_fields = ['project_id', 'region_id']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=f"Missing required field: {field}",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR"
|
||||
)
|
||||
|
||||
# Validate UUID format for project_id and region_id
|
||||
if not is_valid_uuid(data['project_id']):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Invalid UUID format for project_id",
|
||||
status=400,
|
||||
error_type="INVALID_ID_FORMAT"
|
||||
)
|
||||
if not is_valid_uuid(data['region_id']):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Invalid UUID format for region_id",
|
||||
status=400,
|
||||
error_type="INVALID_ID_FORMAT"
|
||||
)
|
||||
|
||||
# Check if the referenced Project and Region exist
|
||||
if not Project.query.get(data['project_id']):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Project not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND"
|
||||
)
|
||||
if not region_exists(data['region_id']):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Region not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND"
|
||||
)
|
||||
|
||||
# Create the RegionAccess instance
|
||||
instance = RegionAccess(
|
||||
project_id=data['project_id'],
|
||||
region_id=data['region_id']
|
||||
)
|
||||
db.session.add(instance)
|
||||
db.session.commit()
|
||||
logger.debug("RegionAccess added to DB")
|
||||
# Audit (log on the Project as the primary object impacted by the access change)
|
||||
try:
|
||||
user_id = get_request_user_id()
|
||||
project = Project.query.get(data['project_id'])
|
||||
if project:
|
||||
AuditEntry.log_event(
|
||||
object=project,
|
||||
action="region_access_granted",
|
||||
description=f"Granted access to region {data['region_id']}",
|
||||
user_id=user_id
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.error(f"Audit logging failed for RegionAccess create: {_e}")
|
||||
return api_response(data=instance.to_json(), status=201, message="RegionAccess created")
|
||||
|
||||
@api_bp.route('/region_access/by_region/<region_id>', methods=['GET'])
|
||||
@require_platform_admin
|
||||
def get_region_access_by_region(region_id):
|
||||
# Validate UUID format for region_id
|
||||
if not is_valid_uuid(region_id):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Invalid UUID format for region_id",
|
||||
status=400,
|
||||
error_type="INVALID_ID_FORMAT"
|
||||
)
|
||||
|
||||
# Fetch all RegionAccess entries for the given region_id
|
||||
logger.debug(f"Fetching region {region_id}")
|
||||
region_access_entries = RegionAccess.query.filter_by(region_id=(region_id)).all()
|
||||
|
||||
# Log the number of entries found
|
||||
logger.debug(f"Found {len(region_access_entries)} entries for region {region_id}")
|
||||
|
||||
# If no entries are found, return an empty array
|
||||
if not region_access_entries:
|
||||
return api_response(data=[])
|
||||
|
||||
# Serialize and return the results
|
||||
return api_response(data=[entry.to_json() for entry in region_access_entries])
|
||||
|
||||
@api_bp.route('/region_access/by_project/<project_id>', methods=['GET'])
|
||||
@require_platform_admin
|
||||
def get_region_access_by_project(project_id):
|
||||
# Validate UUID format for project_id
|
||||
if not is_valid_uuid(project_id):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Invalid UUID format for project_id",
|
||||
status=400,
|
||||
error_type="INVALID_ID_FORMAT"
|
||||
)
|
||||
|
||||
# Fetch the RegionAccess entry
|
||||
region_access = RegionAccess.query.filter_by(project_id=(project_id)).first()
|
||||
if not region_access:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Region access not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND"
|
||||
)
|
||||
return api_response(data=region_access.to_json())
|
||||
|
||||
@api_bp.route('/region_access/<project_id>/<region_id>', methods=['DELETE'])
|
||||
@require_platform_admin
|
||||
def delete_region_access(project_id, region_id):
|
||||
# Validate UUID format for project_id and region_id
|
||||
if not is_valid_uuid(project_id):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Invalid UUID format for project_id",
|
||||
status=400,
|
||||
error_type="INVALID_ID_FORMAT"
|
||||
)
|
||||
if not is_valid_uuid(region_id):
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Invalid UUID format for region_id",
|
||||
status=400,
|
||||
error_type="INVALID_ID_FORMAT"
|
||||
)
|
||||
|
||||
# Fetch the RegionAccess entry
|
||||
region_access = RegionAccess.query.filter_by(project_id=project_id, region_id=region_id).first()
|
||||
if not region_access:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="RegionAccess not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND"
|
||||
)
|
||||
db.session.delete(region_access)
|
||||
db.session.commit()
|
||||
# Audit (log on both Project and Region perspectives)
|
||||
try:
|
||||
user_id = get_request_user_id()
|
||||
project = Project.query.get(project_id)
|
||||
if project:
|
||||
AuditEntry.log_event(
|
||||
object=project,
|
||||
action="region_access_revoked",
|
||||
description=f"Revoked access to region {region_id}",
|
||||
user_id=user_id
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.error(f"Audit logging failed for RegionAccess delete p={project_id} r={region_id}: {_e}")
|
||||
return api_response(message='RegionAccess deleted successfully', status=200)
|
||||
|
||||
@api_bp.route('/region_access', methods=['GET'])
|
||||
@require_platform_admin
|
||||
def get_all_region_access():
|
||||
region_access_list = RegionAccess.query.all()
|
||||
return api_response(data=[access.to_json() for access in region_access_list])
|
||||
@@ -0,0 +1,21 @@
|
||||
from flask import g
|
||||
|
||||
from app import db
|
||||
from app.authz import require_auth
|
||||
from app.models import AuditEntry, SSHKey
|
||||
from app.routes import api_bp
|
||||
from xcloudify_shared.ssh_keys import register_ssh_key_routes
|
||||
|
||||
|
||||
def _audit(action, key, description):
|
||||
AuditEntry.log_event(object=key, action=action, description=description, user_id=g.current_user.id)
|
||||
|
||||
|
||||
register_ssh_key_routes(
|
||||
api_bp,
|
||||
db=db,
|
||||
SSHKey=SSHKey,
|
||||
current_user_id=lambda: g.current_user.id,
|
||||
decorators=[require_auth],
|
||||
on_event=_audit,
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
from flask import request
|
||||
|
||||
from app import db
|
||||
from app.auth_utils import get_request_user_id
|
||||
from app.models import AuditEntry, Universe
|
||||
from app.routes import api_bp
|
||||
from xcloudify_shared import api_response, logger
|
||||
|
||||
|
||||
def _json_body():
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return None, api_response(
|
||||
success=False,
|
||||
status=400,
|
||||
message="A JSON request body is required",
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
return data, None
|
||||
|
||||
|
||||
def _active_universe(universe_id):
|
||||
return Universe.query.filter_by(id=universe_id, deleted=False).first()
|
||||
|
||||
|
||||
def _not_found():
|
||||
return api_response(
|
||||
success=False,
|
||||
status=404,
|
||||
message="Universe not found",
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/universes", methods=["POST"])
|
||||
@api_bp.route("/admin/universes", methods=["POST"])
|
||||
def add_universe():
|
||||
data, error = _json_body()
|
||||
if error:
|
||||
return error
|
||||
|
||||
name = data.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return api_response(
|
||||
success=False,
|
||||
status=400,
|
||||
message="'name' is required",
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
instance = Universe(
|
||||
name=name.strip(),
|
||||
description=data.get("description"),
|
||||
status=data.get("status"),
|
||||
created_by=data.get("created_by") or None,
|
||||
universe_dns_name=data.get("universe_dns_name") or "local",
|
||||
)
|
||||
|
||||
try:
|
||||
db.session.add(instance)
|
||||
db.session.commit()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
db.session.rollback()
|
||||
logger.exception("Failed to create universe")
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Failed to create universe",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
|
||||
logger.debug("Universe added to DB")
|
||||
try:
|
||||
AuditEntry.log_event(
|
||||
object=instance,
|
||||
action="universe_created",
|
||||
description=f"name={instance.name}",
|
||||
user_id=get_request_user_id(),
|
||||
)
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
logger.error("Audit logging failed for universe create %s: %s", instance.id, exc)
|
||||
|
||||
return api_response(
|
||||
data=instance.to_json(),
|
||||
status=201,
|
||||
message="Universe created successfully",
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/universes/<universe_id>", methods=["PUT"])
|
||||
@api_bp.route("/admin/universes/<universe_id>", methods=["PUT"])
|
||||
def edit_universe(universe_id):
|
||||
universe = _active_universe(universe_id)
|
||||
if universe is None:
|
||||
return _not_found()
|
||||
|
||||
data, error = _json_body()
|
||||
if error:
|
||||
return error
|
||||
|
||||
if "name" in data:
|
||||
if not isinstance(data["name"], str) or not data["name"].strip():
|
||||
return api_response(
|
||||
success=False,
|
||||
status=400,
|
||||
message="'name' cannot be empty",
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
universe.name = data["name"].strip()
|
||||
|
||||
for field in ("description", "status", "visible", "universe_dns_name"):
|
||||
if field in data:
|
||||
setattr(universe, field, data[field])
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
db.session.rollback()
|
||||
logger.exception("Failed to update universe %s", universe_id)
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Failed to update universe",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
|
||||
try:
|
||||
fields = ", ".join(data.keys())
|
||||
AuditEntry.log_event(
|
||||
object=universe,
|
||||
action="universe_updated",
|
||||
description=f"Updated fields: {fields}" if fields else "Updated",
|
||||
user_id=get_request_user_id(),
|
||||
)
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
logger.error("Audit logging failed for universe update %s: %s", universe_id, exc)
|
||||
|
||||
return api_response(data=universe.to_json(), message="Universe updated successfully")
|
||||
|
||||
|
||||
@api_bp.route("/universes/<universe_id>", methods=["GET"])
|
||||
@api_bp.route("/admin/universes/<universe_id>", methods=["GET"])
|
||||
def get_universe(universe_id):
|
||||
universe = _active_universe(universe_id)
|
||||
if universe is None:
|
||||
return _not_found()
|
||||
return api_response(data=universe.to_json())
|
||||
|
||||
|
||||
@api_bp.route("/universes/<universe_id>", methods=["DELETE"])
|
||||
@api_bp.route("/admin/universes/<universe_id>", methods=["DELETE"])
|
||||
def delete_universe(universe_id):
|
||||
universe = _active_universe(universe_id)
|
||||
if universe is None:
|
||||
return _not_found()
|
||||
|
||||
try:
|
||||
universe.soft_delete()
|
||||
db.session.commit()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
db.session.rollback()
|
||||
logger.exception("Failed to delete universe %s", universe_id)
|
||||
return api_response(
|
||||
success=False,
|
||||
status=500,
|
||||
message="Failed to delete universe",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
|
||||
try:
|
||||
AuditEntry.log_event(
|
||||
object=universe,
|
||||
action="universe_deleted",
|
||||
description=f"name={universe.name}",
|
||||
user_id=get_request_user_id(),
|
||||
)
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
logger.error("Audit logging failed for universe delete %s: %s", universe_id, exc)
|
||||
|
||||
return api_response(message="Universe deleted successfully")
|
||||
|
||||
|
||||
@api_bp.route("/universes", methods=["GET"])
|
||||
@api_bp.route("/admin/universes", methods=["GET"])
|
||||
def get_universes():
|
||||
universes = Universe.query.filter_by(deleted=False).order_by(Universe.name.asc()).all()
|
||||
return api_response(data=[universe.to_json() for universe in universes])
|
||||
@@ -0,0 +1,224 @@
|
||||
from flask import g, request
|
||||
|
||||
from app import db
|
||||
from app.authz import (
|
||||
require_auth, require_project_role, require_vdc_role, resolve_active_project,
|
||||
visible_projects,
|
||||
)
|
||||
from app.core_client import region_exists
|
||||
from app.models import (
|
||||
CloudflareDNSRecord, CloudflareTunnel, Project, ROLE_READ, User, Vdc, VdcMember, VDC_ROLES,
|
||||
)
|
||||
from app.routes import api_bp
|
||||
from xcloudify_shared import api_response
|
||||
|
||||
|
||||
@api_bp.route("/projects/<project_id>/vdcs", methods=["GET"])
|
||||
@require_project_role("viewer")
|
||||
def list_vdcs(project_id):
|
||||
project = Project.query.get(project_id)
|
||||
return api_response(data=[v.to_json() for v in project.vdcs])
|
||||
|
||||
|
||||
@api_bp.route("/projects/<project_id>/vdcs", methods=["POST"])
|
||||
@require_project_role("member")
|
||||
def create_vdc(project_id):
|
||||
data = request.get_json(force=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
region_id = data.get("region_id")
|
||||
if not name:
|
||||
return api_response(success=False, status=400, message="'name' is required", error_type="VALIDATION_ERROR")
|
||||
if not region_id:
|
||||
return api_response(success=False, status=400, message="'region_id' is required", error_type="VALIDATION_ERROR")
|
||||
if not region_exists(region_id):
|
||||
return api_response(success=False, status=404, message=f"Region {region_id} not found", error_type="NOT_FOUND")
|
||||
|
||||
vdc = Vdc(project_id=project_id, name=name, region_id=region_id)
|
||||
db.session.add(vdc)
|
||||
db.session.commit()
|
||||
return api_response(data=vdc.to_json(), status=201, message="VDC created")
|
||||
|
||||
|
||||
@api_bp.route("/virtual_data_centers", methods=["GET"])
|
||||
@require_auth
|
||||
def list_my_vdcs():
|
||||
"""Every VDC the caller can reach, in the project they are working in.
|
||||
|
||||
The tenant counterpart to /admin/virtual_data_centers: same shape, but
|
||||
scoped to what this user can actually see rather than every tenant on the
|
||||
platform. `?project_id=<id>` selects another project, `?project_id=all`
|
||||
spans every project the caller can see.
|
||||
"""
|
||||
visible_ids = {p.id for p in visible_projects(g.current_user)}
|
||||
if not visible_ids:
|
||||
return api_response(data=[])
|
||||
|
||||
requested = request.args.get("project_id")
|
||||
if requested == "all":
|
||||
scope_ids = visible_ids
|
||||
elif requested:
|
||||
if requested not in visible_ids:
|
||||
return api_response(success=False, status=404, message="Project not found", error_type="NOT_FOUND")
|
||||
scope_ids = {requested}
|
||||
else:
|
||||
active = resolve_active_project(g.current_user)
|
||||
scope_ids = {active.id} if active else set()
|
||||
|
||||
if not scope_ids:
|
||||
return api_response(data=[])
|
||||
|
||||
query = Vdc.query.filter(Vdc.project_id.in_(scope_ids))
|
||||
region_id = request.args.get("region_id")
|
||||
if region_id:
|
||||
query = query.filter(Vdc.region_id == region_id)
|
||||
|
||||
return api_response(data=[v.to_json() for v in query.all()])
|
||||
|
||||
|
||||
@api_bp.route("/virtual_data_centers/<vdc_id>", methods=["GET"])
|
||||
@require_vdc_role("read")
|
||||
def get_vdc_by_resource_name(vdc_id):
|
||||
"""Alias of GET /vdcs/<id> under the resource name the portal uses."""
|
||||
return api_response(data=Vdc.query.get(vdc_id).to_json())
|
||||
|
||||
|
||||
@api_bp.route("/vdcs/<vdc_id>", methods=["GET"])
|
||||
@require_vdc_role("read")
|
||||
def get_vdc(vdc_id):
|
||||
vdc = Vdc.query.get(vdc_id)
|
||||
return api_response(data=vdc.to_json())
|
||||
|
||||
|
||||
@api_bp.route("/vdcs/<vdc_id>/exposures", methods=["GET"])
|
||||
@require_vdc_role(ROLE_READ)
|
||||
def list_vdc_exposures(vdc_id):
|
||||
"""The public hostnames for this VDC's `use_dns` container ports.
|
||||
|
||||
Two halves, because they answer different questions:
|
||||
|
||||
`cloudflare_domain` is the project's zone, and the hostname a port will
|
||||
get is fully derived from it -- `{container_name}-{internal_port}.{domain}`
|
||||
(see tasks/dns_exposure.py, which builds exactly that string). The tunnel
|
||||
name never appears in it. So a caller holding the domain can predict every
|
||||
hostname without asking, which is why it is returned here rather than left
|
||||
to /projects/<id>/cloudflare -- that route is owner-only, and a read-only
|
||||
VDC member needs the domain just as much.
|
||||
|
||||
`exposures` is what was actually provisioned. The distinction matters:
|
||||
dns_exposure skips silently when the project has no credentials, has no
|
||||
domain on file, or the pod has no NSController, and gives up after three
|
||||
retries if Cloudflare itself fails. A derived hostname with no matching
|
||||
row here is a port that was asked to be public and isn't.
|
||||
|
||||
Keyed by container_workload_id so the caller can join it onto the
|
||||
containers it already renders.
|
||||
|
||||
Builds the payload by hand rather than calling to_json(): CloudflareTunnel
|
||||
inherits BaseModel's dump-every-column to_json, which would hand out
|
||||
`token` and `tunnel_secret` -- the sidecar's credential for the tunnel.
|
||||
"""
|
||||
vdc = Vdc.query.get(vdc_id)
|
||||
records = (
|
||||
CloudflareDNSRecord.query
|
||||
.join(CloudflareTunnel, CloudflareDNSRecord.tunnel_id == CloudflareTunnel.id)
|
||||
.filter(
|
||||
CloudflareTunnel.vdc_id == vdc_id,
|
||||
CloudflareDNSRecord.deleted == False, # noqa: E712
|
||||
CloudflareTunnel.deleted == False, # noqa: E712
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return api_response(data={
|
||||
"cloudflare_domain": vdc.project.cloudflare_domain if vdc and vdc.project else None,
|
||||
"exposures": [
|
||||
{
|
||||
"id": r.id,
|
||||
"hostname": r.hostname,
|
||||
"url": f"https://{r.hostname}",
|
||||
"container_workload_id": r.container_workload_id,
|
||||
"internal_port": r.internal_port,
|
||||
"proxied": r.proxied,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"tunnel": {
|
||||
"id": r.tunnel.id,
|
||||
"name": r.tunnel.name,
|
||||
"tunnel_id": r.tunnel.tunnel_id,
|
||||
"pod_id": r.tunnel.pod_id,
|
||||
} if r.tunnel else None,
|
||||
}
|
||||
for r in records
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@api_bp.route("/vdcs/<vdc_id>/members", methods=["GET"])
|
||||
@require_vdc_role("read")
|
||||
def list_vdc_members(vdc_id):
|
||||
members = VdcMember.query.filter_by(vdc_id=vdc_id).all()
|
||||
return api_response(data=[m.to_json() for m in members])
|
||||
|
||||
|
||||
@api_bp.route("/vdcs/<vdc_id>/members", methods=["POST"])
|
||||
@require_vdc_role("write")
|
||||
def add_vdc_member(vdc_id):
|
||||
vdc = Vdc.query.get(vdc_id)
|
||||
if vdc is None:
|
||||
return api_response(success=False, status=404, message="VDC not found", error_type="NOT_FOUND")
|
||||
data = request.get_json(force=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
role = data.get("role")
|
||||
if not email:
|
||||
return api_response(success=False, status=400, message="'email' is required", error_type="VALIDATION_ERROR")
|
||||
if role not in VDC_ROLES:
|
||||
return api_response(success=False, status=400, message=f"'role' must be one of {VDC_ROLES}", error_type="VALIDATION_ERROR")
|
||||
|
||||
existing = VdcMember.query.filter_by(vdc_id=vdc_id, email=email).first()
|
||||
if existing:
|
||||
existing.role = role
|
||||
db.session.commit()
|
||||
return api_response(data=existing.to_json(), message="Member role updated")
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
member = VdcMember(vdc_id=vdc_id, email=email, role=role, user_id=user.id if user else None)
|
||||
db.session.add(member)
|
||||
db.session.commit()
|
||||
return api_response(data=member.to_json(), status=201, message="Member invited")
|
||||
|
||||
|
||||
@api_bp.route("/vdcs/<vdc_id>/members/<member_id>", methods=["DELETE"])
|
||||
@require_vdc_role("write")
|
||||
def remove_vdc_member(vdc_id, member_id):
|
||||
vdc = Vdc.query.get(vdc_id)
|
||||
if vdc is None:
|
||||
return api_response(success=False, status=404, message="VDC not found", error_type="NOT_FOUND")
|
||||
member = VdcMember.query.filter_by(id=member_id, vdc_id=vdc_id).first()
|
||||
if member is None:
|
||||
return api_response(success=False, status=404, message="Member not found", error_type="NOT_FOUND")
|
||||
db.session.delete(member)
|
||||
db.session.commit()
|
||||
return api_response(message="Member removed")
|
||||
|
||||
|
||||
@api_bp.route("/regions", methods=["GET"])
|
||||
@require_auth
|
||||
def list_regions_passthrough():
|
||||
"""Regions come from core; VDC creation needs a list to pick from."""
|
||||
from app.core_client import list_regions
|
||||
try:
|
||||
return api_response(data=list_regions())
|
||||
except Exception as exc:
|
||||
return api_response(success=False, status=502, message=f"Could not reach core: {exc}", error_type="UPSTREAM_ERROR")
|
||||
|
||||
|
||||
@api_bp.route("/images", methods=["GET"])
|
||||
@require_auth
|
||||
def list_images_passthrough():
|
||||
"""Images come from core and aren't tenant-owned (no tenant_id on the
|
||||
model), so -- like regions -- this is a plain authenticated passthrough
|
||||
rather than a per-VDC gateway route. VM creation needs a list to pick a
|
||||
boot image from."""
|
||||
from app.core_client import list_images
|
||||
try:
|
||||
return api_response(data=list_images())
|
||||
except Exception as exc:
|
||||
return api_response(success=False, status=502, message=f"Could not reach core: {exc}", error_type="UPSTREAM_ERROR")
|
||||
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
from app import celery_app as celery, logger
|
||||
from app.models import CloudflareTunnel, Project
|
||||
from xcloudify_shared.cloudflare import CloudflareTunnelManager
|
||||
|
||||
|
||||
def _reconcile_project(project: Project) -> Dict[str, Any]:
|
||||
creds = project.cloudflare_credentials()
|
||||
api_token, account_id, zone_id = creds
|
||||
cf_manager = CloudflareTunnelManager(api_token, account_id, zone_id, logger)
|
||||
|
||||
counts = {"db_marked_deleted": 0, "still_in_cloudflare": 0, "deleted_successfully": 0, "delete_failed": 0}
|
||||
|
||||
from app.models import Vdc
|
||||
vdc_ids = {v.id for v in Vdc.query.filter_by(project_id=project.id).all()}
|
||||
deleted_tunnels = [
|
||||
t for t in CloudflareTunnel.query.filter(CloudflareTunnel.deleted == True).all()
|
||||
if t.vdc_id in vdc_ids
|
||||
]
|
||||
counts["db_marked_deleted"] = len(deleted_tunnels)
|
||||
if not deleted_tunnels:
|
||||
return counts
|
||||
|
||||
try:
|
||||
response = cf_manager._make_request("GET", f"/accounts/{account_id}/cfd_tunnel")
|
||||
except Exception as exc:
|
||||
logger.error("Reconciliation: failed to list Cloudflare tunnels for project %s: %s", project.id, exc)
|
||||
raise
|
||||
|
||||
live_tunnels = {t["id"]: t for t in response.get("result", []) if t.get("id")}
|
||||
|
||||
for tunnel in deleted_tunnels:
|
||||
live = live_tunnels.get(tunnel.tunnel_id)
|
||||
if not live or live.get("deleted_at"):
|
||||
continue
|
||||
counts["still_in_cloudflare"] += 1
|
||||
try:
|
||||
result = cf_manager.cleanup_tunnel(tunnel.name, delete_tunnel=True)
|
||||
if result.get("tunnel_deleted", False):
|
||||
counts["deleted_successfully"] += 1
|
||||
else:
|
||||
logger.warning("Reconciliation: cleanup did not delete tunnel %s (%s)", tunnel.name, tunnel.tunnel_id)
|
||||
counts["delete_failed"] += 1
|
||||
except Exception as exc:
|
||||
logger.error("Reconciliation: failed to clean up tunnel %s (%s): %s", tunnel.name, tunnel.tunnel_id, exc)
|
||||
counts["delete_failed"] += 1
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
@celery.task(name="tasks.cloud_cloudflare_reconciliation", bind=True)
|
||||
def cloudflare_reconciliation_task(self) -> dict:
|
||||
run_started_at = datetime.utcnow()
|
||||
summary = {"run_started_at": run_started_at.isoformat() + "Z", "projects": {}}
|
||||
|
||||
projects = [p for p in Project.query.all() if p.cloudflare_configured]
|
||||
if not projects:
|
||||
summary["run_finished_at"] = datetime.utcnow().isoformat() + "Z"
|
||||
summary["status"] = "skipped"
|
||||
summary["reason"] = "no project has Cloudflare configured"
|
||||
return summary
|
||||
|
||||
for project in projects:
|
||||
try:
|
||||
summary["projects"][project.id] = _reconcile_project(project)
|
||||
except Exception as exc:
|
||||
summary["projects"][project.id] = {"status": "failed", "error": str(exc)}
|
||||
|
||||
summary["run_finished_at"] = datetime.utcnow().isoformat() + "Z"
|
||||
summary["status"] = "completed"
|
||||
logger.info("Cloudflare reconciliation completed: %s", summary)
|
||||
return summary
|
||||
@@ -0,0 +1,237 @@
|
||||
from typing import Dict, List
|
||||
|
||||
from app import celery_app as celery, db
|
||||
from app.core_client import core_request
|
||||
from app.models import CloudflareDNSRecord, CloudflareTunnel, Vdc
|
||||
from xcloudify_shared.cloudflare import CloudflareTunnelManager
|
||||
from xcloudify_shared import logger
|
||||
|
||||
|
||||
def _cf_manager_for_vdc(vdc: Vdc) -> CloudflareTunnelManager | None:
|
||||
project = vdc.project
|
||||
creds = project.cloudflare_credentials()
|
||||
if not creds:
|
||||
logger.warning(
|
||||
"VDC %s requested public DNS exposure but project %s has no Cloudflare "
|
||||
"credentials configured (PUT /projects/%s/cloudflare) -- skipping",
|
||||
vdc.id, project.id, project.id,
|
||||
)
|
||||
return None
|
||||
api_token, account_id, zone_id = creds
|
||||
return CloudflareTunnelManager(api_token, account_id, zone_id, logger)
|
||||
|
||||
|
||||
@celery.task(name="tasks.cloud_provision_exposure", bind=True, max_retries=3)
|
||||
def provision_exposure(self, vdc_id: str, pod_id: str, exposures: List[Dict]) -> None:
|
||||
"""
|
||||
exposures: [{"container_workload_id": str, "container_name": str, "internal_port": int}, ...]
|
||||
for containers in this pod whose port_mapping had use_dns=true.
|
||||
"""
|
||||
if not exposures:
|
||||
return
|
||||
|
||||
vdc = Vdc.query.get(vdc_id)
|
||||
if not vdc:
|
||||
logger.error("provision_exposure: VDC %s not found", vdc_id)
|
||||
return
|
||||
|
||||
cf_mgr = _cf_manager_for_vdc(vdc)
|
||||
if not cf_mgr:
|
||||
return
|
||||
|
||||
tunnel_domain = vdc.project.cloudflare_domain
|
||||
if not tunnel_domain:
|
||||
logger.error(
|
||||
"provision_exposure: project %s has no cloudflare_domain on file "
|
||||
"(credentials saved before this was tracked) -- re-PUT "
|
||||
"/projects/%s/cloudflare to backfill it, skipping",
|
||||
vdc.project.id, vdc.project.id,
|
||||
)
|
||||
return
|
||||
|
||||
resp = core_request("GET", f"workloads/pods/{pod_id}")
|
||||
resp.raise_for_status()
|
||||
pod_detail = resp.json().get("data") or {}
|
||||
nscontroller = pod_detail.get("nscontroller")
|
||||
if not nscontroller:
|
||||
logger.error("provision_exposure: pod %s has no NSController, cannot expose", pod_id)
|
||||
return
|
||||
nscontroller_workload_id = nscontroller["id"]
|
||||
|
||||
tunnel = CloudflareTunnel.query.filter_by(pod_id=pod_id, deleted=False).first()
|
||||
|
||||
ingress_mappings = [
|
||||
{
|
||||
"dns_hostname": f"{e['container_name']}-{e['internal_port']}.{tunnel_domain}",
|
||||
"local_ip": "127.0.0.1",
|
||||
"local_port": str(e["internal_port"]),
|
||||
}
|
||||
for e in exposures
|
||||
]
|
||||
|
||||
try:
|
||||
cf_rsp = cf_mgr.setup_tunnel(
|
||||
tunnel_name=tunnel.name if tunnel else f"tun-{pod_id}",
|
||||
ingress_mappings=ingress_mappings,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("provision_exposure: Cloudflare setup_tunnel failed for pod %s: %s", pod_id, exc)
|
||||
self.retry(exc=exc, countdown=30)
|
||||
return
|
||||
|
||||
is_new_tunnel = tunnel is None
|
||||
if is_new_tunnel:
|
||||
tunnel = CloudflareTunnel(
|
||||
vdc_id=vdc_id,
|
||||
account_id=cf_mgr.account_id,
|
||||
tunnel_id=cf_rsp["tunnel_id"],
|
||||
name=cf_rsp["tunnel_name"],
|
||||
tunnel_secret=cf_rsp.get("tunnel_secret") or "",
|
||||
token=cf_rsp["token"],
|
||||
nscontroller_workload_id=nscontroller_workload_id,
|
||||
pod_id=pod_id,
|
||||
)
|
||||
db.session.add(tunnel)
|
||||
db.session.flush()
|
||||
|
||||
for e, mapping in zip(exposures, ingress_mappings):
|
||||
hostname = mapping["dns_hostname"]
|
||||
existing = CloudflareDNSRecord.query.filter_by(
|
||||
tunnel_id=tunnel.id, container_workload_id=e["container_workload_id"],
|
||||
internal_port=e["internal_port"], deleted=False,
|
||||
).first()
|
||||
if existing:
|
||||
continue
|
||||
dns_id = next(
|
||||
(d["response"]["id"] for d in cf_rsp.get("dns_records_created", []) if d["hostname"] == hostname),
|
||||
None,
|
||||
)
|
||||
if not dns_id:
|
||||
found = cf_mgr.get_dns_record(hostname)
|
||||
dns_id = found["id"] if found else None
|
||||
if not dns_id:
|
||||
logger.error("provision_exposure: no Cloudflare DNS record id for %s", hostname)
|
||||
continue
|
||||
db.session.add(CloudflareDNSRecord(
|
||||
name="dns",
|
||||
zone_id=cf_mgr.zone_id,
|
||||
dns_record_id=dns_id,
|
||||
hostname=hostname,
|
||||
content=f"{tunnel.tunnel_id}.cfargotunnel.com",
|
||||
tunnel_id=tunnel.id,
|
||||
container_workload_id=e["container_workload_id"],
|
||||
internal_port=e["internal_port"],
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if is_new_tunnel:
|
||||
_create_cloudflared_sidecar(pod_id=pod_id, vdc_id=vdc_id, tunnel_token=tunnel.token)
|
||||
|
||||
|
||||
def _create_cloudflared_sidecar(*, pod_id: str, vdc_id: str, tunnel_token: str) -> None:
|
||||
"""Add the cloudflared sidecar to the pod via core's normal container API.
|
||||
It joins the pod like any other container -- core auto-attaches it to the
|
||||
NSController's network namespace the same way it does every container.
|
||||
"""
|
||||
resp = core_request("POST", "workloads/containers", json={
|
||||
"pod": pod_id,
|
||||
"tenant_id": vdc_id,
|
||||
"containers": [{
|
||||
"docker_image": "cloudflare/cloudflared:latest",
|
||||
"container_name": f"cloudflared-sidecar-{pod_id}",
|
||||
"command": f"tunnel --no-autoupdate run --token {tunnel_token}",
|
||||
"restart_policy": "always",
|
||||
"cpu": 1,
|
||||
"mem_limit": 64,
|
||||
}],
|
||||
})
|
||||
if resp.status_code >= 300:
|
||||
logger.error("Failed to create cloudflared sidecar for pod %s: %s %s", pod_id, resp.status_code, resp.text)
|
||||
else:
|
||||
logger.info("Created cloudflared sidecar for pod %s", pod_id)
|
||||
|
||||
|
||||
@celery.task(name="tasks.cloud_cleanup_exposure", bind=True, max_retries=3)
|
||||
def cleanup_exposure(self, container_workload_id: str) -> None:
|
||||
"""Called after a container is deleted through the gateway. Removes any
|
||||
DNS record for that container; if its tunnel has no records left,
|
||||
deletes the tunnel and the cloudflared sidecar too.
|
||||
"""
|
||||
records = CloudflareDNSRecord.query.filter_by(
|
||||
container_workload_id=container_workload_id, deleted=False
|
||||
).all()
|
||||
if not records:
|
||||
return
|
||||
|
||||
tunnel_ids = {r.tunnel_id for r in records if r.tunnel_id}
|
||||
|
||||
for record in records:
|
||||
tunnel = CloudflareTunnel.query.get(record.tunnel_id) if record.tunnel_id else None
|
||||
if tunnel:
|
||||
vdc = Vdc.query.get(tunnel.vdc_id)
|
||||
cf_mgr = _cf_manager_for_vdc(vdc) if vdc else None
|
||||
if cf_mgr:
|
||||
try:
|
||||
cf_mgr.delete_dns_record(record.dns_record_id)
|
||||
except Exception as exc:
|
||||
logger.error("cleanup_exposure: failed to delete DNS record %s: %s", record.hostname, exc)
|
||||
record.soft_delete()
|
||||
db.session.add(record)
|
||||
db.session.commit()
|
||||
|
||||
for tunnel_id in tunnel_ids:
|
||||
tunnel = CloudflareTunnel.query.get(tunnel_id)
|
||||
if not tunnel:
|
||||
continue
|
||||
remaining = CloudflareDNSRecord.query.filter_by(tunnel_id=tunnel_id, deleted=False).count()
|
||||
if remaining:
|
||||
continue
|
||||
_teardown_tunnel(tunnel, delete_sidecar=True)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@celery.task(name="tasks.cloud_cleanup_exposure_for_pod", bind=True, max_retries=3)
|
||||
def cleanup_exposure_for_pod(self, pod_id: str) -> None:
|
||||
"""Called after a whole pod is deleted through the gateway. core has
|
||||
already deleted the pod's containers, including any cloudflared sidecar
|
||||
-- this only tears down the Cloudflare-side tunnel/DNS and local rows.
|
||||
"""
|
||||
tunnels = CloudflareTunnel.query.filter_by(pod_id=pod_id, deleted=False).all()
|
||||
for tunnel in tunnels:
|
||||
records = CloudflareDNSRecord.query.filter_by(tunnel_id=tunnel.id, deleted=False).all()
|
||||
vdc = Vdc.query.get(tunnel.vdc_id)
|
||||
cf_mgr = _cf_manager_for_vdc(vdc) if vdc else None
|
||||
for record in records:
|
||||
if cf_mgr:
|
||||
try:
|
||||
cf_mgr.delete_dns_record(record.dns_record_id)
|
||||
except Exception as exc:
|
||||
logger.error("cleanup_exposure_for_pod: failed to delete DNS record %s: %s", record.hostname, exc)
|
||||
record.soft_delete()
|
||||
db.session.add(record)
|
||||
_teardown_tunnel(tunnel, delete_sidecar=False)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _teardown_tunnel(tunnel: CloudflareTunnel, *, delete_sidecar: bool) -> None:
|
||||
vdc = Vdc.query.get(tunnel.vdc_id)
|
||||
cf_mgr = _cf_manager_for_vdc(vdc) if vdc else None
|
||||
if cf_mgr:
|
||||
try:
|
||||
cf_mgr.cleanup_tunnel(tunnel.name, delete_tunnel=True)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to delete Cloudflare tunnel %s: %s", tunnel.name, exc)
|
||||
|
||||
if delete_sidecar and vdc:
|
||||
resp = core_request("GET", "workloads/containers", params={"tenant_id": vdc.id})
|
||||
if resp.ok:
|
||||
for c in resp.json().get("data", []):
|
||||
if c.get("name") == f"cloudflared-sidecar-{tunnel.pod_id}":
|
||||
del_resp = core_request("DELETE", f"workloads/containers/{c['id']}")
|
||||
if del_resp.status_code >= 300:
|
||||
logger.error("Failed to delete cloudflared sidecar %s: %s", c["id"], del_resp.text)
|
||||
break
|
||||
|
||||
tunnel.soft_delete()
|
||||
db.session.add(tunnel)
|
||||
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
Certificate Utility Functions
|
||||
|
||||
This module provides functions for generating self-signed CA certificates,
|
||||
issuing certificates, and managing certificate revocation using the
|
||||
cryptography library.
|
||||
"""
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
from app import db
|
||||
from xcloudify_shared import logger
|
||||
from app.certificate_models import Certificate, CertificateAuthority, CertificateRevocationList
|
||||
|
||||
|
||||
def generate_self_signed_ca(common_name, country=None, state=None, city=None,
|
||||
organization=None, organizational_unit=None,
|
||||
validity_years=5, crl_url=None):
|
||||
"""
|
||||
Generate a self-signed CA certificate.
|
||||
|
||||
Args:
|
||||
common_name (str): Common name for the certificate (e.g., rootca.projectid.xcloudify.tech)
|
||||
country (str, optional): Country code (2 letters)
|
||||
state (str, optional): State or province
|
||||
city (str, optional): City or locality
|
||||
organization (str, optional): Organization name
|
||||
organizational_unit (str, optional): Organizational unit
|
||||
validity_years (int): Number of years the certificate is valid (default: 5)
|
||||
crl_url (str, optional): URL for CRL distribution point
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing the private key, certificate, and public key
|
||||
"""
|
||||
# Generate private key
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
|
||||
# Create subject name
|
||||
subject_name = []
|
||||
if country:
|
||||
subject_name.append(x509.NameAttribute(NameOID.COUNTRY_NAME, country))
|
||||
if state:
|
||||
subject_name.append(x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, state))
|
||||
if city:
|
||||
subject_name.append(x509.NameAttribute(NameOID.LOCALITY_NAME, city))
|
||||
if organization:
|
||||
subject_name.append(x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization))
|
||||
if organizational_unit:
|
||||
subject_name.append(x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, organizational_unit))
|
||||
subject_name.append(x509.NameAttribute(NameOID.COMMON_NAME, common_name))
|
||||
|
||||
subject = issuer = x509.Name(subject_name)
|
||||
|
||||
# Create certificate builder
|
||||
serial_number=x509.random_serial_number()
|
||||
cert_builder = x509.CertificateBuilder().subject_name(
|
||||
subject
|
||||
).issuer_name(
|
||||
issuer
|
||||
).public_key(
|
||||
private_key.public_key()
|
||||
).serial_number(
|
||||
serial_number
|
||||
).not_valid_before(
|
||||
datetime.utcnow()
|
||||
).not_valid_after(
|
||||
datetime.utcnow() + timedelta(days=365 * validity_years)
|
||||
).add_extension(
|
||||
x509.BasicConstraints(ca=True, path_length=None), critical=True,
|
||||
).add_extension(
|
||||
x509.KeyUsage(
|
||||
key_cert_sign=True,
|
||||
crl_sign=True,
|
||||
digital_signature=False,
|
||||
content_commitment=False,
|
||||
key_encipherment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False
|
||||
),
|
||||
critical=True
|
||||
)
|
||||
|
||||
# Add CRL distribution point if provided
|
||||
if crl_url:
|
||||
crl_dp = x509.DistributionPoint(
|
||||
full_name=[x509.UniformResourceIdentifier(crl_url)],
|
||||
relative_name=None,
|
||||
reasons=None,
|
||||
crl_issuer=None
|
||||
)
|
||||
cert_builder = cert_builder.add_extension(
|
||||
x509.CRLDistributionPoints([crl_dp]),
|
||||
critical=False
|
||||
)
|
||||
|
||||
# Sign the certificate
|
||||
cert = cert_builder.sign(private_key, hashes.SHA256())
|
||||
|
||||
# Serialize private key
|
||||
private_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
# Serialize certificate
|
||||
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
|
||||
|
||||
return {
|
||||
"private_key": private_pem.decode('utf-8'),
|
||||
"certificate": cert_pem.decode('utf-8'),
|
||||
"public_key": private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
).decode('utf-8'),
|
||||
"serial_number": serial_number
|
||||
}
|
||||
|
||||
|
||||
def issue_certificate(ca_private_key_pem, ca_cert_pem, common_name,
|
||||
certificate_type="server", country=None, state=None,
|
||||
city=None, organization=None, organizational_unit=None,
|
||||
email=None, validity_years=1):
|
||||
"""
|
||||
Issue a certificate signed by a CA.
|
||||
|
||||
Args:
|
||||
ca_private_key_pem (str): PEM-encoded CA private key
|
||||
ca_cert_pem (str): PEM-encoded CA certificate
|
||||
common_name (str): Common name for the certificate
|
||||
certificate_type (str): Type of certificate (server, client, code_signing)
|
||||
country (str, optional): Country code (2 letters)
|
||||
state (str, optional): State or province
|
||||
city (str, optional): City or locality
|
||||
organization (str, optional): Organization name
|
||||
organizational_unit (str, optional): Organizational unit
|
||||
email (str, optional): Email address
|
||||
validity_years (int): Number of years the certificate is valid (default: 1)
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing the private key, certificate, and public key
|
||||
"""
|
||||
# Load CA private key
|
||||
ca_private_key = serialization.load_pem_private_key(
|
||||
ca_private_key_pem.encode('utf-8'),
|
||||
password=None,
|
||||
)
|
||||
|
||||
# Load CA certificate
|
||||
ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode('utf-8'))
|
||||
|
||||
# Generate private key for new certificate
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
|
||||
# Create subject name
|
||||
subject_name = []
|
||||
if country:
|
||||
subject_name.append(x509.NameAttribute(NameOID.COUNTRY_NAME, country))
|
||||
if state:
|
||||
subject_name.append(x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, state))
|
||||
if city:
|
||||
subject_name.append(x509.NameAttribute(NameOID.LOCALITY_NAME, city))
|
||||
if organization:
|
||||
subject_name.append(x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization))
|
||||
if organizational_unit:
|
||||
subject_name.append(x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, organizational_unit))
|
||||
if email:
|
||||
subject_name.append(x509.NameAttribute(NameOID.EMAIL_ADDRESS, email))
|
||||
subject_name.append(x509.NameAttribute(NameOID.COMMON_NAME, common_name))
|
||||
|
||||
subject = x509.Name(subject_name)
|
||||
|
||||
# Determine key usage based on certificate type
|
||||
if certificate_type == "server":
|
||||
key_usage = x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
key_encipherment=True,
|
||||
key_cert_sign=False,
|
||||
crl_sign=False,
|
||||
content_commitment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False
|
||||
)
|
||||
extended_key_usage = x509.ExtendedKeyUsage([
|
||||
x509.oid.ExtendedKeyUsageOID.SERVER_AUTH
|
||||
])
|
||||
elif certificate_type == "client":
|
||||
key_usage = x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
key_encipherment=True,
|
||||
key_cert_sign=False,
|
||||
crl_sign=False,
|
||||
content_commitment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False
|
||||
)
|
||||
extended_key_usage = x509.ExtendedKeyUsage([
|
||||
x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH
|
||||
])
|
||||
elif certificate_type == "code_signing":
|
||||
key_usage = x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
key_cert_sign=False,
|
||||
crl_sign=False,
|
||||
content_commitment=True,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False
|
||||
)
|
||||
extended_key_usage = x509.ExtendedKeyUsage([
|
||||
x509.oid.ExtendedKeyUsageOID.CODE_SIGNING
|
||||
])
|
||||
else:
|
||||
key_usage = x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
key_encipherment=True,
|
||||
key_cert_sign=False,
|
||||
crl_sign=False,
|
||||
content_commitment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False
|
||||
)
|
||||
extended_key_usage = None
|
||||
serial_number=x509.random_serial_number()
|
||||
|
||||
# Create certificate
|
||||
cert_builder = x509.CertificateBuilder().subject_name(
|
||||
subject
|
||||
).issuer_name(
|
||||
ca_cert.subject
|
||||
).public_key(
|
||||
private_key.public_key()
|
||||
).serial_number(
|
||||
serial_number
|
||||
).not_valid_before(
|
||||
datetime.utcnow()
|
||||
).not_valid_after(
|
||||
datetime.utcnow() + timedelta(days=365 * validity_years)
|
||||
).add_extension(
|
||||
key_usage, critical=True
|
||||
)
|
||||
|
||||
if extended_key_usage:
|
||||
cert_builder = cert_builder.add_extension(extended_key_usage, critical=False)
|
||||
|
||||
# Add subject alternative name for server certificates
|
||||
if certificate_type == "server":
|
||||
cert_builder = cert_builder.add_extension(
|
||||
x509.SubjectAlternativeName([x509.DNSName(common_name)]),
|
||||
critical=False
|
||||
)
|
||||
|
||||
cert = cert_builder.sign(ca_private_key, hashes.SHA256())
|
||||
|
||||
# Serialize private key
|
||||
private_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
# Serialize certificate
|
||||
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
|
||||
|
||||
return {
|
||||
"private_key": private_pem.decode('utf-8'),
|
||||
"certificate": cert_pem.decode('utf-8'),
|
||||
"public_key": private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
).decode('utf-8'),
|
||||
"serial_number": serial_number
|
||||
}
|
||||
|
||||
|
||||
def encrypt_private_key(private_key_pem, password):
|
||||
"""
|
||||
Encrypt a private key using a password.
|
||||
|
||||
Args:
|
||||
private_key_pem (str): PEM-encoded private key
|
||||
password (str): Password for encryption
|
||||
|
||||
Returns:
|
||||
str: Encrypted private key
|
||||
"""
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.fernet import Fernet
|
||||
import os
|
||||
import base64
|
||||
|
||||
# Generate a salt
|
||||
salt = os.urandom(16)
|
||||
|
||||
# Derive key from password
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=100000,
|
||||
)
|
||||
key = kdf.derive(password.encode())
|
||||
|
||||
# Encode key as base64 for Fernet
|
||||
fernet_key = base64.urlsafe_b64encode(key)
|
||||
|
||||
# Encrypt the private key
|
||||
f = Fernet(fernet_key)
|
||||
encrypted_key = f.encrypt(private_key_pem.encode())
|
||||
|
||||
# Return salt + encrypted key
|
||||
return salt.hex() + encrypted_key.hex()
|
||||
|
||||
|
||||
def decrypt_private_key(encrypted_data, password):
|
||||
"""
|
||||
Decrypt a private key using a password.
|
||||
|
||||
Args:
|
||||
encrypted_data (str): Encrypted private key (salt + encrypted key)
|
||||
password (str): Password for decryption
|
||||
|
||||
Returns:
|
||||
str: Decrypted PEM-encoded private key
|
||||
"""
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.fernet import Fernet
|
||||
import base64
|
||||
|
||||
# Extract salt and encrypted key
|
||||
salt = bytes.fromhex(encrypted_data[:32])
|
||||
encrypted_key = bytes.fromhex(encrypted_data[32:])
|
||||
|
||||
# Derive key from password
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=100000,
|
||||
)
|
||||
key = kdf.derive(password.encode())
|
||||
|
||||
# Encode key as base64 for Fernet
|
||||
fernet_key = base64.urlsafe_b64encode(key)
|
||||
|
||||
# Decrypt the private key
|
||||
f = Fernet(fernet_key)
|
||||
decrypted_key = f.decrypt(encrypted_key)
|
||||
|
||||
return decrypted_key.decode()
|
||||
|
||||
|
||||
def generateCRL(ca: CertificateAuthority):
|
||||
# Get all revoked certificates for this CA
|
||||
revoked_certs = Certificate.query.filter_by(ca_id=ca.id, revoked=True).all()
|
||||
|
||||
if ca.current_crl:
|
||||
current_crl_number=ca.current_crl.crl_number
|
||||
else:
|
||||
current_crl_number=1
|
||||
|
||||
# Decrypt CA private key (in a real implementation, this would require additional authentication)
|
||||
# For this example, we'll use the project_id as the password
|
||||
decrypted_ca_private_key = decrypt_private_key(ca.private_key, ca.project_id)
|
||||
|
||||
# Load CA private key
|
||||
ca_private_key = serialization.load_pem_private_key(
|
||||
decrypted_ca_private_key.encode('utf-8'),
|
||||
password=None,
|
||||
)
|
||||
|
||||
# Load CA certificate
|
||||
ca_cert = x509.load_pem_x509_certificate(ca.certificate_data.encode('utf-8'))
|
||||
|
||||
# Create CRL builder
|
||||
crl_builder = x509.CertificateRevocationListBuilder().issuer_name(
|
||||
ca_cert.subject
|
||||
).last_update(
|
||||
datetime.utcnow()
|
||||
).next_update(
|
||||
datetime.utcnow() + timedelta(days=30)
|
||||
)
|
||||
logger.debug("attempting to buld crl")
|
||||
# Add all revoked certificates to the CRL
|
||||
for _cert in revoked_certs:
|
||||
revoked_cert = x509.RevokedCertificateBuilder().serial_number(
|
||||
int(_cert.serial_number)
|
||||
).revocation_date(
|
||||
_cert.revoked_at
|
||||
).build()
|
||||
crl_builder = crl_builder.add_revoked_certificate(revoked_cert)
|
||||
logger.debug("buld crl done")
|
||||
|
||||
# Add CRL number extension
|
||||
crl_builder = crl_builder.add_extension(
|
||||
x509.CRLNumber(current_crl_number+1),
|
||||
critical=False
|
||||
)
|
||||
|
||||
# Sign the CRL
|
||||
crl = crl_builder.sign(ca_private_key, hashes.SHA256())
|
||||
|
||||
# Serialize CRL
|
||||
crl_pem = crl.public_bytes(serialization.Encoding.PEM)
|
||||
|
||||
crl_data = crl_pem.decode('utf-8')
|
||||
|
||||
# Create or update CRL record
|
||||
crl = CertificateRevocationList(
|
||||
crl_number=current_crl_number + 1,
|
||||
crl_data=crl_data,
|
||||
next_update=datetime.utcnow() + timedelta(days=30)
|
||||
)
|
||||
|
||||
db.session.add(crl)
|
||||
db.session.flush() # Get the ID of the new CRL
|
||||
|
||||
# Update CA's current CRL reference
|
||||
ca.current_crl_id = crl.id
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
|
||||
name: core
|
||||
|
||||
include:
|
||||
- path: ../core/docker-compose.yml
|
||||
|
||||
services:
|
||||
cloud-db:
|
||||
image: mariadb:10.11
|
||||
container_name: xcloudify-cloud-db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
MYSQL_DATABASE: cloud
|
||||
MYSQL_USER: cloud_user
|
||||
MYSQL_PASSWORD: cloud_password
|
||||
ports:
|
||||
- "3307:3306"
|
||||
volumes:
|
||||
- cloud_db_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
cloud-db-migrate:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: cloud/Dockerfile
|
||||
image: xcloudify-cloud
|
||||
env_file: .env
|
||||
command: python manage.py migrations:apply
|
||||
depends_on:
|
||||
cloud-db:
|
||||
condition: service_healthy
|
||||
|
||||
cloud-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: xcloudify-cloud-redis
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
cloud-celery-worker:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: cloud/Dockerfile
|
||||
image: xcloudify-cloud
|
||||
container_name: xcloudify-cloud-celery-worker
|
||||
env_file: .env
|
||||
command: ["celery", "-A", "app.celery_app.celery", "worker", "--loglevel=info"]
|
||||
depends_on:
|
||||
cloud-db:
|
||||
condition: service_healthy
|
||||
cloud-db-migrate:
|
||||
condition: service_completed_successfully
|
||||
cloud-redis:
|
||||
condition: service_healthy
|
||||
api-server:
|
||||
condition: service_healthy
|
||||
|
||||
cloud-celery-beat:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: cloud/Dockerfile
|
||||
image: xcloudify-cloud
|
||||
container_name: xcloudify-cloud-celery-beat
|
||||
env_file: .env
|
||||
command: ["celery", "-A", "app.celery_app.celery", "beat", "--loglevel=info"]
|
||||
depends_on:
|
||||
cloud-db:
|
||||
condition: service_healthy
|
||||
cloud-db-migrate:
|
||||
condition: service_completed_successfully
|
||||
cloud-redis:
|
||||
condition: service_healthy
|
||||
|
||||
cloud-api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: cloud/Dockerfile
|
||||
image: xcloudify-cloud
|
||||
container_name: xcloudify-cloud-api
|
||||
env_file: .env
|
||||
environment:
|
||||
FLASK_APP: api_server.py
|
||||
volumes:
|
||||
- .:/app
|
||||
- ../packages/pyshared:/packages/pyshared
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "flask run --host=0.0.0.0 --port=5001 --debug"
|
||||
ports:
|
||||
- "5001:5001"
|
||||
depends_on:
|
||||
cloud-db:
|
||||
condition: service_healthy
|
||||
cloud-db-migrate:
|
||||
condition: service_completed_successfully
|
||||
cloud-redis:
|
||||
condition: service_healthy
|
||||
api-server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5001/api/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
|
||||
oauth2-proxy:
|
||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
|
||||
container_name: xcloudify-plane-oauth2-proxy
|
||||
profiles: ["auth"]
|
||||
command: ["--config=/oauth2-proxy.cfg"]
|
||||
volumes:
|
||||
- ./oauth2-proxy.cfg:/oauth2-proxy.cfg:ro
|
||||
environment:
|
||||
OAUTH2_PROXY_OIDC_ISSUER_URL: ${OIDC_ISSUER:-https://secuird.tech/}
|
||||
OAUTH2_PROXY_CLIENT_ID: ${OAUTH2_PROXY_CLIENT_ID:-}
|
||||
OAUTH2_PROXY_CLIENT_SECRET: ${OAUTH2_PROXY_CLIENT_SECRET:-}
|
||||
OAUTH2_PROXY_COOKIE_SECRET: ${OAUTH2_PROXY_COOKIE_SECRET:-}
|
||||
OAUTH2_PROXY_REDIRECT_URL: ${OAUTH2_PROXY_REDIRECT_URL:-http://localhost:8090/oauth2/callback}
|
||||
OAUTH2_PROXY_COOKIE_SECURE: ${OAUTH2_PROXY_COOKIE_SECURE:-false}
|
||||
OAUTH2_PROXY_UPSTREAMS: ${OAUTH2_PROXY_UPSTREAMS:-http://localhost:8080/}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "8090:8090"
|
||||
depends_on:
|
||||
cloud-api:
|
||||
condition: service_healthy
|
||||
|
||||
|
||||
volumes:
|
||||
cloud_db_data:
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
from flask_migrate import (
|
||||
init as alembic_init,
|
||||
migrate as alembic_migrate,
|
||||
upgrade as alembic_upgrade,
|
||||
downgrade as alembic_downgrade,
|
||||
current as alembic_current,
|
||||
history as alembic_history,
|
||||
stamp as alembic_stamp,
|
||||
)
|
||||
|
||||
from app import app, db # noqa: F401
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@click.group()
|
||||
def command_line_interface():
|
||||
pass
|
||||
|
||||
|
||||
@command_line_interface.command("migrations:init")
|
||||
@with_appcontext
|
||||
def init_migrations():
|
||||
from pathlib import Path
|
||||
if Path("migrations").exists():
|
||||
logger.info("migrations/ already exists")
|
||||
else:
|
||||
alembic_init()
|
||||
alembic_stamp(revision="head")
|
||||
|
||||
|
||||
@command_line_interface.command("migrations:generate")
|
||||
@click.option("--message", "-m", default="schema update")
|
||||
@with_appcontext
|
||||
def generate_migration(message):
|
||||
alembic_migrate(message=message)
|
||||
|
||||
|
||||
@command_line_interface.command("migrations:apply")
|
||||
@with_appcontext
|
||||
def apply_migrations():
|
||||
alembic_upgrade()
|
||||
|
||||
|
||||
@command_line_interface.command("migrations:current")
|
||||
@with_appcontext
|
||||
def current_migration():
|
||||
alembic_current(verbose=True)
|
||||
|
||||
|
||||
@command_line_interface.command("migrations:history")
|
||||
@with_appcontext
|
||||
def migration_history():
|
||||
alembic_history(verbose=True)
|
||||
|
||||
|
||||
@command_line_interface.command("migrations:downgrade")
|
||||
@click.option("--revision", "-r", required=True)
|
||||
@with_appcontext
|
||||
def downgrade_migration(revision):
|
||||
alembic_downgrade(revision=revision)
|
||||
|
||||
|
||||
def _set_admin(email: str, value: bool):
|
||||
from app.models import User
|
||||
email = email.strip().lower()
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if user is None:
|
||||
raise click.ClickException(
|
||||
f"No user with email {email}. They must log in once before they can be "
|
||||
f"granted operator access -- accounts are created from the IdP identity, "
|
||||
f"not by this command."
|
||||
)
|
||||
user.is_platform_admin = value
|
||||
db.session.commit()
|
||||
logger.info("%s is_platform_admin=%s", email, value)
|
||||
|
||||
|
||||
@command_line_interface.command("admin:grant")
|
||||
@click.argument("email")
|
||||
@with_appcontext
|
||||
def grant_admin(email):
|
||||
"""Give an existing user platform-operator access."""
|
||||
_set_admin(email, True)
|
||||
|
||||
|
||||
@command_line_interface.command("admin:revoke")
|
||||
@click.argument("email")
|
||||
@with_appcontext
|
||||
def revoke_admin(email):
|
||||
"""Remove platform-operator access."""
|
||||
_set_admin(email, False)
|
||||
|
||||
|
||||
@command_line_interface.command("admin:list")
|
||||
@with_appcontext
|
||||
def list_admins():
|
||||
"""Show who currently holds operator access."""
|
||||
from app.models import User
|
||||
admins = User.query.filter_by(is_platform_admin=True).all()
|
||||
if not admins:
|
||||
click.echo("No platform operators. Grant one with: manage.py admin:grant <email>")
|
||||
return
|
||||
for u in admins:
|
||||
click.echo(f"{u.email}\t{u.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
command_line_interface()
|
||||
@@ -0,0 +1,53 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# Path to migration scripts (relative to this file's directory)
|
||||
script_location = .
|
||||
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions['migrate'].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions['migrate'].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||
'%', '%%')
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace('%', '%%')
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||
target_db = current_app.extensions['migrate'].db
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, 'metadatas'):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
conf_args = current_app.extensions['migrate'].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,57 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0001_initial'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('id', sa.String(length=36), primary_key=True),
|
||||
sa.Column('oidc_id', sa.String(length=255), nullable=False, unique=True),
|
||||
sa.Column('email', sa.String(length=255), nullable=False, unique=True),
|
||||
sa.Column('first_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('last_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('avatar_url', sa.String(length=512), nullable=True),
|
||||
sa.Column('is_platform_admin', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('last_login_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'projects',
|
||||
sa.Column('id', sa.String(length=36), primary_key=True),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=36), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'vdcs',
|
||||
sa.Column('id', sa.String(length=36), primary_key=True),
|
||||
sa.Column('project_id', sa.String(length=36), sa.ForeignKey('projects.id'), nullable=False, index=True),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('region_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'vdc_members',
|
||||
sa.Column('id', sa.String(length=36), primary_key=True),
|
||||
sa.Column('vdc_id', sa.String(length=36), sa.ForeignKey('vdcs.id'), nullable=False, index=True),
|
||||
sa.Column('email', sa.String(length=255), nullable=False, index=True),
|
||||
sa.Column('user_id', sa.String(length=36), sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('role', sa.String(length=20), nullable=False, server_default='read'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint('vdc_id', 'email', name='uq_vdc_member_email'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('vdc_members')
|
||||
op.drop_table('vdcs')
|
||||
op.drop_table('projects')
|
||||
op.drop_table('users')
|
||||
@@ -0,0 +1,121 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
revision = '0002_ported_from_core'
|
||||
down_revision = '0001_initial'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _base_columns():
|
||||
"""Core's BaseModel shape, kept identical so ported routes work unchanged."""
|
||||
return [
|
||||
sa.Column('id', sa.String(length=36), primary_key=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('deleted', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column('visible', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('name', sa.String(length=255), nullable=False, server_default=''),
|
||||
sa.Column('description', mysql.LONGTEXT(), nullable=True),
|
||||
sa.Column('status', sa.String(length=50), nullable=True),
|
||||
sa.Column('created_by', sa.String(length=36), sa.ForeignKey('users.id'), nullable=True),
|
||||
]
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'universes',
|
||||
*_base_columns(),
|
||||
sa.Column('universe_dns_name', sa.String(length=255), nullable=False, server_default='local'),
|
||||
)
|
||||
|
||||
op.add_column('projects', sa.Column('universe_id', sa.String(length=36), nullable=True))
|
||||
op.create_foreign_key('fk_projects_universe_id', 'projects', 'universes', ['universe_id'], ['id'])
|
||||
|
||||
op.create_table(
|
||||
'region_access',
|
||||
sa.Column('project_id', sa.String(length=36), sa.ForeignKey('projects.id'), primary_key=True),
|
||||
sa.Column('region_id', sa.String(length=36), primary_key=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'audit_entry',
|
||||
sa.Column('id', sa.String(length=36), primary_key=True),
|
||||
sa.Column('object_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('object_type', sa.String(length=255), nullable=False),
|
||||
sa.Column('audit_text', mysql.LONGTEXT(), nullable=False),
|
||||
sa.Column('audit_entry_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('additional_data', mysql.LONGTEXT(), nullable=True),
|
||||
sa.Column('is_error', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'certificate_authorities',
|
||||
*_base_columns(),
|
||||
sa.Column('project_id', sa.String(length=36), sa.ForeignKey('projects.id'), nullable=False, unique=True),
|
||||
sa.Column('common_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('country', sa.String(length=2), nullable=True),
|
||||
sa.Column('state', sa.String(length=255), nullable=True),
|
||||
sa.Column('city', sa.String(length=255), nullable=True),
|
||||
sa.Column('organization', sa.String(length=255), nullable=True),
|
||||
sa.Column('organizational_unit', sa.String(length=255), nullable=True),
|
||||
sa.Column('domain_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('validity_period', sa.Integer(), nullable=False, server_default='5'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('private_key', mysql.LONGTEXT(), nullable=True),
|
||||
sa.Column('certificate_data', mysql.LONGTEXT(), nullable=True),
|
||||
sa.Column('serial_number', sa.String(length=255), nullable=True),
|
||||
sa.Column('current_crl_id', sa.String(length=36), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'certificates',
|
||||
*_base_columns(),
|
||||
sa.Column('ca_id', sa.String(length=36),
|
||||
sa.ForeignKey('certificate_authorities.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('certificate_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('common_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('country', sa.String(length=2), nullable=True),
|
||||
sa.Column('state', sa.String(length=255), nullable=True),
|
||||
sa.Column('city', sa.String(length=255), nullable=True),
|
||||
sa.Column('organization', sa.String(length=255), nullable=True),
|
||||
sa.Column('organizational_unit', sa.String(length=255), nullable=True),
|
||||
sa.Column('email', sa.String(length=255), nullable=True),
|
||||
sa.Column('validity_period', sa.Integer(), nullable=False, server_default='1'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('revoked', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('serial_number', sa.BigInteger(), nullable=True),
|
||||
sa.Column('public_key', mysql.LONGTEXT(), nullable=True),
|
||||
sa.Column('private_key', mysql.LONGTEXT(), nullable=True),
|
||||
sa.Column('certificate_data', mysql.LONGTEXT(), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'certificate_revocation_lists',
|
||||
*_base_columns(),
|
||||
sa.Column('ca_id', sa.String(length=36),
|
||||
sa.ForeignKey('certificate_authorities.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('crl_number', sa.Integer(), nullable=False),
|
||||
sa.Column('crl_data', mysql.LONGTEXT(), nullable=True),
|
||||
sa.Column('next_update', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
op.create_foreign_key('fk_ca_current_crl_id', 'certificate_authorities',
|
||||
'certificate_revocation_lists', ['current_crl_id'], ['id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_constraint('fk_ca_current_crl_id', 'certificate_authorities', type_='foreignkey')
|
||||
op.drop_table('certificate_revocation_lists')
|
||||
op.drop_table('certificates')
|
||||
op.drop_table('certificate_authorities')
|
||||
op.drop_table('audit_entry')
|
||||
op.drop_table('region_access')
|
||||
op.drop_constraint('fk_projects_universe_id', 'projects', type_='foreignkey')
|
||||
op.drop_column('projects', 'universe_id')
|
||||
op.drop_table('universes')
|
||||
@@ -0,0 +1,63 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
revision = '0003_cloudflare'
|
||||
down_revision = '0002_ported_from_core'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _base_columns():
|
||||
"""Core's BaseModel shape, kept identical so ported routes work unchanged."""
|
||||
return [
|
||||
sa.Column('id', sa.String(length=36), primary_key=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('deleted', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column('visible', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('name', sa.String(length=255), nullable=False, server_default=''),
|
||||
sa.Column('description', mysql.LONGTEXT(), nullable=True),
|
||||
sa.Column('status', sa.String(length=50), nullable=True),
|
||||
sa.Column('created_by', sa.String(length=36), sa.ForeignKey('users.id'), nullable=True),
|
||||
]
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'cloudflare_tunnels',
|
||||
*_base_columns(),
|
||||
sa.Column('vdc_id', sa.String(length=36), sa.ForeignKey('vdcs.id'), nullable=False),
|
||||
sa.Column('account_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('tunnel_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('tunnel_secret', sa.String(length=255), nullable=False),
|
||||
sa.Column('token', sa.String(length=255), nullable=False),
|
||||
sa.Column('associated_hostname', sa.String(length=255), nullable=True),
|
||||
sa.Column('notes', mysql.LONGTEXT(), nullable=True),
|
||||
sa.Column('nscontroller_workload_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('pod_id', sa.String(length=36), nullable=True),
|
||||
sa.UniqueConstraint('tunnel_id'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'cloudflare_dns_records',
|
||||
*_base_columns(),
|
||||
sa.Column('zone_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('dns_record_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('hostname', sa.String(length=255), nullable=False),
|
||||
sa.Column('record_type', sa.String(length=50), nullable=False, server_default='CNAME'),
|
||||
sa.Column('content', sa.String(length=255), nullable=False),
|
||||
sa.Column('ttl', sa.Integer(), nullable=True, server_default='120'),
|
||||
sa.Column('proxied', sa.Boolean(), nullable=True, server_default=sa.true()),
|
||||
sa.Column('notes', mysql.LONGTEXT(), nullable=True),
|
||||
sa.Column('tunnel_id', sa.String(length=36), sa.ForeignKey('cloudflare_tunnels.id'), nullable=True),
|
||||
sa.Column('container_workload_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('internal_port', sa.Integer(), nullable=True),
|
||||
sa.UniqueConstraint('dns_record_id'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('cloudflare_dns_records')
|
||||
op.drop_table('cloudflare_tunnels')
|
||||
@@ -0,0 +1,31 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
revision = '0004_project_cf_creds'
|
||||
down_revision = '0003_cloudflare'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
existing = {c['name'] for c in sa.inspect(conn).get_columns('projects')}
|
||||
|
||||
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||
if 'cloudflare_api_token_encrypted' not in existing:
|
||||
batch_op.add_column(sa.Column('cloudflare_api_token_encrypted', mysql.LONGTEXT(), nullable=True))
|
||||
if 'cloudflare_account_id' not in existing:
|
||||
batch_op.add_column(sa.Column('cloudflare_account_id', sa.String(length=255), nullable=True))
|
||||
if 'cloudflare_zone_id' not in existing:
|
||||
batch_op.add_column(sa.Column('cloudflare_zone_id', sa.String(length=255), nullable=True))
|
||||
if 'cloudflare_verified_at' not in existing:
|
||||
batch_op.add_column(sa.Column('cloudflare_verified_at', sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||
batch_op.drop_column('cloudflare_verified_at')
|
||||
batch_op.drop_column('cloudflare_zone_id')
|
||||
batch_op.drop_column('cloudflare_account_id')
|
||||
batch_op.drop_column('cloudflare_api_token_encrypted')
|
||||
@@ -0,0 +1,20 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0005_cf_domain'
|
||||
down_revision = '0004_project_cf_creds'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
existing = {c['name'] for c in sa.inspect(conn).get_columns('projects')}
|
||||
if 'cloudflare_domain' not in existing:
|
||||
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('cloudflare_domain', sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||
batch_op.drop_column('cloudflare_domain')
|
||||
@@ -0,0 +1,31 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0006_ssh_keys'
|
||||
down_revision = '0005_cf_domain'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'ssh_keys',
|
||||
sa.Column('id', sa.String(length=36), primary_key=True),
|
||||
sa.Column('user_id', sa.String(length=36), sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('key_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('public_key_data', sa.Text(), nullable=False),
|
||||
sa.Column('key_fingerprint', sa.String(length=255), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('deleted', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
with op.batch_alter_table('ssh_keys', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_ssh_keys_user_id'), ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('ssh_keys', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_ssh_keys_user_id'))
|
||||
op.drop_table('ssh_keys')
|
||||
@@ -0,0 +1,68 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0007_project_switching'
|
||||
down_revision = '0006_ssh_keys'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
project_columns = {c['name'] for c in inspector.get_columns('projects')}
|
||||
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||
if 'kind' not in project_columns:
|
||||
batch_op.add_column(sa.Column(
|
||||
'kind', sa.String(length=20), nullable=False, server_default='personal',
|
||||
))
|
||||
if 'description' not in project_columns:
|
||||
batch_op.add_column(sa.Column('description', sa.String(length=512), nullable=True))
|
||||
|
||||
user_columns = {c['name'] for c in inspector.get_columns('users')}
|
||||
if 'active_project_id' not in user_columns:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('active_project_id', sa.String(length=36), nullable=True))
|
||||
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
if 'project_members' not in tables:
|
||||
op.create_table(
|
||||
'project_members',
|
||||
sa.Column('id', sa.String(length=36), primary_key=True),
|
||||
sa.Column('project_id', sa.String(length=36), sa.ForeignKey('projects.id'), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('role', sa.String(length=20), nullable=False, server_default='member'),
|
||||
sa.Column('source', sa.String(length=20), nullable=False, server_default='invite'),
|
||||
sa.Column('idp_group', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.UniqueConstraint('project_id', 'email', name='uq_project_member_email'),
|
||||
)
|
||||
with op.batch_alter_table('project_members', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_project_members_project_id'), ['project_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_project_members_email'), ['email'], unique=False)
|
||||
|
||||
if 'project_group_bindings' not in tables:
|
||||
op.create_table(
|
||||
'project_group_bindings',
|
||||
sa.Column('id', sa.String(length=36), primary_key=True),
|
||||
sa.Column('project_id', sa.String(length=36), sa.ForeignKey('projects.id'), nullable=False),
|
||||
sa.Column('group_name', sa.String(length=255), nullable=False, unique=True),
|
||||
sa.Column('role', sa.String(length=20), nullable=False, server_default='member'),
|
||||
sa.Column('auto_created', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
)
|
||||
with op.batch_alter_table('project_group_bindings', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_project_group_bindings_project_id'), ['project_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('project_group_bindings')
|
||||
op.drop_table('project_members')
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_column('active_project_id')
|
||||
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||
batch_op.drop_column('description')
|
||||
batch_op.drop_column('kind')
|
||||
@@ -0,0 +1,22 @@
|
||||
http_address = "0.0.0.0:8090"
|
||||
reverse_proxy = true
|
||||
|
||||
provider = "oidc"
|
||||
scope = "openid profile email"
|
||||
email_domains = ["*"]
|
||||
insecure_oidc_allow_unverified_email = true
|
||||
|
||||
set_authorization_header = true
|
||||
set_xauthrequest = true
|
||||
pass_authorization_header = true
|
||||
skip_provider_button = true
|
||||
|
||||
skip_auth_routes = [
|
||||
"^/$",
|
||||
"^/assets/",
|
||||
"^/favicon",
|
||||
"^/robots.txt"
|
||||
]
|
||||
api_routes = [
|
||||
"^/api/"
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
flask
|
||||
flask_sqlalchemy
|
||||
flask_migrate
|
||||
flask_cors
|
||||
pymysql
|
||||
requests
|
||||
pyjwt[crypto]
|
||||
colorlog
|
||||
click
|
||||
gunicorn
|
||||
celery
|
||||
redis
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
|
||||
|
||||
def _env(key: str, default: str = "") -> str:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
def _env_bool(key: str, default: bool = False) -> bool:
|
||||
val = os.environ.get(key)
|
||||
if val is None:
|
||||
return default
|
||||
return val.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
CLOUD_DATABASE_URL = _env(
|
||||
"CLOUD_DATABASE_URL",
|
||||
"mysql+pymysql://cloud_user:cloud_password@127.0.0.1:3307/cloud",
|
||||
)
|
||||
|
||||
CORE_API_BASE_URL = _env("CORE_API_BASE_URL", "http://172.17.0.1:5000")
|
||||
CORE_API_KEY = _env("CORE_API_KEY", "")
|
||||
|
||||
FLASK_ENV = _env("FLASK_ENV", "development")
|
||||
|
||||
|
||||
OIDC_ISSUER = _env("OIDC_ISSUER", "https://secuird.tech/")
|
||||
OIDC_JWKS_URL = _env("OIDC_JWKS_URL", "")
|
||||
OIDC_AUDIENCE = _env("OIDC_AUDIENCE", "")
|
||||
OIDC_ADDITIONAL_AUDIENCES = _env("OIDC_ADDITIONAL_AUDIENCES", "")
|
||||
OIDC_SUBJECT_CLAIM = _env("OIDC_SUBJECT_CLAIM", "sub")
|
||||
OIDC_EMAIL_CLAIM = _env("OIDC_EMAIL_CLAIM", "email")
|
||||
# Unset means production. The dev bypass is only possible in a development
|
||||
# APP_ENV (see xcloudify_shared.env.dev_bypass_enabled), where it is on unless
|
||||
# AUTH_DEV_BYPASS=false.
|
||||
APP_ENV = _env("APP_ENV", "production")
|
||||
AUTH_DEV_BYPASS = _env_bool("AUTH_DEV_BYPASS") if os.environ.get("AUTH_DEV_BYPASS") else None
|
||||
|
||||
OIDC_GROUPS_CLAIMS = _env("OIDC_GROUPS_CLAIMS") or _env(
|
||||
"OIDC_GROUPS_CLAIM",
|
||||
"groups,roles,realm_access.roles,openstack_groups,openstack_project_names,memberOf",
|
||||
)
|
||||
IDP_PROJECT_AUTOCREATE = _env_bool("IDP_PROJECT_AUTOCREATE", True)
|
||||
IDP_PROJECT_GROUP_PATTERN = _env("IDP_PROJECT_GROUP_PATTERN", "")
|
||||
IDP_PROJECT_IGNORED_GROUPS = _env(
|
||||
"IDP_PROJECT_IGNORED_GROUPS",
|
||||
"offline_access,uma_authorization,account,default-roles-*,*/*-realm,everyone,users,authenticated",
|
||||
)
|
||||
IDP_PROJECT_DEFAULT_ROLE = _env("IDP_PROJECT_DEFAULT_ROLE", "member")
|
||||
IDP_PROJECT_NAME_TEMPLATE = _env("IDP_PROJECT_NAME_TEMPLATE", "{group_title}")
|
||||
LOCAL_USER_EMAIL = os.getenv("LOCAL_USER_EMAIL", "local@xcloudify.dev")
|
||||
LOCAL_USER_GROUPS = _env("LOCAL_USER_GROUPS", "")
|
||||
|
||||
REDIS_BROKER_URL = _env("CLOUD_BROKER_URL", "redis://cloud-redis:6379/0")
|
||||
REDIS_RESULT_BACKEND_URL = _env("CLOUD_RESULT_BACKEND", "redis://cloud-redis:6379/1")
|
||||
|
||||
CLOUD_SECRET_KEY = _env("CLOUD_SECRET_KEY", "")
|
||||
Reference in New Issue
Block a user