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:
2026-09-02 11:15:25 +05:45
parent ca29ba96e3
commit b25c6cd24e
42 changed files with 5091 additions and 0 deletions
+45
View File
@@ -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=
+20
View File
@@ -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"]
+4
View File
@@ -0,0 +1,4 @@
from app import app # noqa: F401
if __name__ == "__main__":
app.run(debug=True, port=5001, host="0.0.0.0")
+123
View File
@@ -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")
+158
View File
@@ -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
+232
View File
@@ -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))
+19
View File
@@ -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
+223
View File
@@ -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
+22
View File
@@ -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
+7
View File
@@ -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
+193
View File
@@ -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]
+432
View File
@@ -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,
}
+7
View File
@@ -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,
)
+203
View File
@@ -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")
+23
View File
@@ -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)
+740
View File
@@ -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)}
)
+98
View File
@@ -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)
+227
View File
@@ -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")
+202
View File
@@ -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])
+21
View File
@@ -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,
)
+190
View File
@@ -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])
+224
View File
@@ -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")
View File
@@ -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
+237
View File
@@ -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)
View File
+443
View File
@@ -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()
+138
View File
@@ -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
View File
@@ -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()
+53
View File
@@ -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
+113
View File
@@ -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()
+24
View File
@@ -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"}
+57
View File
@@ -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')
+22
View File
@@ -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/"
]
+12
View File
@@ -0,0 +1,12 @@
flask
flask_sqlalchemy
flask_migrate
flask_cors
pymysql
requests
pyjwt[crypto]
colorlog
click
gunicorn
celery
redis
+56
View File
@@ -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", "")