Includes:
- compositor.app, compositor.session, compositor.session.app, compositor.audit.log models
- /web/compositor/{token,apps,launch,audit} HTTP controllers with JWT (HS256)
- CompositorService OWL service (reusable via useService('compositor'))
- CompositorCanvas OWL component (reusable canvas wrapper)
- Security: compositor_group_user, compositor_group_manager groups + ir.access.csv rules
- 5 demo apps pre-loaded (gnome-calculator, xterm, gtk4-demo, glxgears, featherpad)
- Audit log capturing JWT issuance + app launch events
- Stub mode: launching apps returns a stub SurfaceHandle when proxy isn't connected
- Tested against Odoo 20 + PostgreSQL 17
Author: 1bspace.com
Parent company: 1B Global
License: Proprietary (1B Space internal use)
309 lines
13 KiB
Python
309 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""1B Space Compositor HTTP controllers.
|
|
|
|
All compositor-related endpoints live under ``/web/compositor/*`` so they're
|
|
easy to route through Nginx (single-origin HTTPS via
|
|
``location /compositor/ { proxy_pass http://127.0.0.1:8081; }``).
|
|
"""
|
|
import datetime
|
|
import logging
|
|
import secrets
|
|
|
|
from odoo import http, fields
|
|
from odoo.http import request
|
|
from odoo.exceptions import UserError, AccessError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
import jwt
|
|
except ImportError: # pragma: no cover
|
|
jwt = None
|
|
_logger.warning("PyJWT not installed — compositor auth will fail. "
|
|
"Install with: pip install pyjwt")
|
|
|
|
|
|
class CompositorController(http.Controller):
|
|
"""All compositor HTTP endpoints.
|
|
|
|
The OWL shell calls these endpoints (via ``rpc`` service) to:
|
|
1. Get a JWT (``/web/compositor/token``)
|
|
2. List available apps (``/web/compositor/apps``)
|
|
3. Launch an app (``/web/compositor/launch``)
|
|
4. Terminate an app (``/web/compositor/terminate``)
|
|
5. Ingest audit events from the proxy (``/web/compositor/audit``)
|
|
|
|
The actual compositor proxy (Python aiohttp server, running pywayland +
|
|
GStreamer + bubblewrap) lives in the sister addon ``one_os_compositor_proxy``.
|
|
"""
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# JWT issuance
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
@http.route("/web/compositor/token", type="json", auth="user", methods=["POST"])
|
|
def get_token(self):
|
|
"""Issue a 15-min JWT (HS256) for the compositor proxy.
|
|
|
|
:return: ``{jwt, proxy_url, expires_at, compositor_session_id}``
|
|
:raises UserError: if JWT secret is not configured
|
|
"""
|
|
if jwt is None:
|
|
raise UserError("PyJWT is not installed in Odoo's environment.")
|
|
|
|
secret = request.env["ir.config_parameter"].sudo().get_str("compositor.jwt_secret")
|
|
if not secret:
|
|
raise UserError(
|
|
"Compositor JWT secret is not configured. "
|
|
"Set ir.config_parameter 'compositor.jwt_secret'."
|
|
)
|
|
|
|
user = request.env.user
|
|
# Reuse existing session for this user if they have one active, else create.
|
|
Session = request.env["compositor.session"]
|
|
session = Session.search([
|
|
("user_id", "=", user.id),
|
|
("state", "in", ["pending", "active", "disconnected"]),
|
|
], order="started_at desc", limit=1)
|
|
if not session:
|
|
session = Session.create({
|
|
"user_id": user.id,
|
|
"state": "pending",
|
|
})
|
|
session.touch()
|
|
|
|
# Compose JWT payload
|
|
now = datetime.datetime.utcnow()
|
|
expires_at = now + datetime.timedelta(minutes=15)
|
|
payload = {
|
|
"iss": request.httprequest.host,
|
|
"aud": "compositor-proxy",
|
|
"sub": user.id,
|
|
"db": request.db,
|
|
"company_ids": user.company_ids.ids,
|
|
"scope": "compositor",
|
|
"compositor_session_id": session.compositor_session_id,
|
|
"iat": now,
|
|
"exp": expires_at,
|
|
}
|
|
token = jwt.encode(payload, secret, algorithm="HS256")
|
|
|
|
# Log issuance
|
|
request.env["compositor.audit.log"].sudo().log_event(
|
|
"jwt_issued",
|
|
user_id=user.id,
|
|
session_id=session.id,
|
|
client_ip=request.httprequest.remote_addr,
|
|
user_agent=request.httprequest.environ.get("HTTP_USER_AGENT", ""),
|
|
)
|
|
|
|
# Update session with new JWT info
|
|
session.write({
|
|
"last_jwt_issued_at": fields.Datetime.to_datetime(now),
|
|
"last_jwt_expires_at": fields.Datetime.to_datetime(expires_at),
|
|
"state": "active",
|
|
})
|
|
|
|
proxy_url = request.env["ir.config_parameter"].sudo().get_str(
|
|
"compositor.proxy_url",
|
|
default="http://localhost:8081",
|
|
)
|
|
|
|
return {
|
|
"jwt": token,
|
|
"proxy_url": proxy_url,
|
|
"expires_at": expires_at.isoformat() + "Z",
|
|
"compositor_session_id": session.compositor_session_id,
|
|
"session_db_id": session.id,
|
|
}
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# App catalog
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
@http.route("/web/compositor/apps", type="json", auth="user", methods=["POST"])
|
|
def list_apps(self):
|
|
"""List apps the current user is allowed to launch.
|
|
|
|
Filters by:
|
|
- ``active = True``
|
|
- ``allowed_group_ids`` empty (visible to all internal users) OR
|
|
current user is in one of the allowed groups
|
|
- Apps visible in the current user's company
|
|
"""
|
|
user = request.env.user
|
|
domain = [
|
|
("active", "=", True),
|
|
"|",
|
|
("allowed_group_ids", "=", False),
|
|
("allowed_group_ids", "in", user.group_ids.ids),
|
|
]
|
|
apps = request.env["compositor.app"].search_read(
|
|
domain,
|
|
["app_id", "name", "description", "icon", "color", "running_session_count"],
|
|
)
|
|
# icon is base64 — leave as-is, the OWL shell will use it directly
|
|
return {"apps": apps}
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# App launch / terminate
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
@http.route("/web/compositor/launch", type="json", auth="user", methods=["POST"])
|
|
def launch_app(self, app_id):
|
|
"""Tell the compositor proxy to spawn a sandboxed app.
|
|
|
|
:param app_id: The ``compositor.app.app_id`` (string identifier)
|
|
:return: ``{signal_url, pid, compositor_surface_id, app_instance_db_id}``
|
|
"""
|
|
user = request.env.user
|
|
app = request.env["compositor.app"].search([
|
|
("app_id", "=", app_id),
|
|
"|",
|
|
("allowed_group_ids", "=", False),
|
|
("allowed_group_ids", "in", user.group_ids.ids),
|
|
], limit=1)
|
|
if not app:
|
|
raise AccessError(f"You don't have access to launch {app_id!r}")
|
|
|
|
# Get or create the user's active session
|
|
session = request.env["compositor.session"].search([
|
|
("user_id", "=", user.id),
|
|
("state", "in", ["pending", "active", "disconnected"]),
|
|
], order="started_at desc", limit=1)
|
|
if not session:
|
|
raise UserError("No active compositor session. Call /web/compositor/token first.")
|
|
|
|
# Create an app-instance record (state=launching)
|
|
inst = request.env["compositor.session.app"].create({
|
|
"session_id": session.id,
|
|
"app_id": app.id,
|
|
"state": "launching",
|
|
"title": app.name,
|
|
})
|
|
|
|
# Build the launch config to send to the proxy
|
|
launch_config = app.get_launch_config()
|
|
launch_config.update({
|
|
"compositor_session_id": session.compositor_session_id,
|
|
"app_instance_db_id": inst.id,
|
|
"odoo_user_id": user.id,
|
|
"odoo_db": request.db,
|
|
})
|
|
|
|
# In the full impl: POST this to the Python compositor proxy via
|
|
# aiohttp. For now (proxy not yet built), return a stub response
|
|
# that the OWL shell can use to develop against.
|
|
# TODO: replace with real HTTP call once one_os_compositor_proxy is built
|
|
try:
|
|
proxy_response = self._call_proxy_launch(launch_config)
|
|
except NotImplementedError:
|
|
# Proxy not yet connected — return a stub so the OWL shell can develop
|
|
_logger.info("Compositor proxy not yet connected — returning stub response for %s", app_id)
|
|
proxy_response = {
|
|
"signal_url": None,
|
|
"pid": -1,
|
|
"compositor_surface_id": f"stub-{inst.id}",
|
|
"stub": True,
|
|
}
|
|
|
|
# Update the app-instance record
|
|
inst.write({
|
|
"state": "running" if not proxy_response.get("stub") else "launching",
|
|
"pid": proxy_response.get("pid", -1),
|
|
"signal_url": proxy_response.get("signal_url"),
|
|
"compositor_surface_id": proxy_response.get("compositor_surface_id"),
|
|
})
|
|
|
|
# Audit log
|
|
request.env["compositor.audit.log"].sudo().log_event(
|
|
"app_launch",
|
|
user_id=user.id,
|
|
session_id=session.id,
|
|
app_id=app.id,
|
|
app_instance_id=inst.id,
|
|
pid=proxy_response.get("pid"),
|
|
)
|
|
|
|
return {
|
|
"signal_url": proxy_response.get("signal_url"),
|
|
"pid": proxy_response.get("pid"),
|
|
"compositor_surface_id": proxy_response.get("compositor_surface_id"),
|
|
"app_instance_db_id": inst.id,
|
|
"stub": proxy_response.get("stub", False),
|
|
}
|
|
|
|
@http.route("/web/compositor/terminate", type="json", auth="user", methods=["POST"])
|
|
def terminate_app(self, app_instance_db_id, force=False):
|
|
"""Tell the compositor proxy to terminate an app.
|
|
|
|
:param app_instance_db_id: The ID of the ``compositor.session.app`` record
|
|
:param force: If True, send SIGKILL instead of SIGTERM
|
|
"""
|
|
user = request.env.user
|
|
inst = request.env["compositor.session.app"].browse(app_instance_db_id)
|
|
if not inst.exists() or inst.session_id.user_id != user:
|
|
raise AccessError("You don't have access to terminate this app instance.")
|
|
|
|
# TODO: call the proxy to actually kill the process
|
|
inst.write({
|
|
"state": "terminated",
|
|
"ended_at": fields.Datetime.now(),
|
|
"signal_received": "SIGKILL" if force else "SIGTERM",
|
|
})
|
|
|
|
request.env["compositor.audit.log"].sudo().log_event(
|
|
"app_force_kill" if force else "app_terminate",
|
|
user_id=user.id,
|
|
session_id=inst.session_id.id,
|
|
app_id=inst.app_id.id,
|
|
app_instance_id=inst.id,
|
|
pid=inst.pid,
|
|
)
|
|
|
|
return {"ok": True}
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# Audit ingestion (called by the proxy)
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
@http.route("/web/compositor/audit", type="json", auth="user", methods=["POST"])
|
|
def audit_callback(self, **kwargs):
|
|
"""Ingest audit events from the Python compositor proxy.
|
|
|
|
The proxy calls this endpoint to report app crashes, exits, etc.
|
|
|
|
:param kwargs: Field values for the new ``compositor.audit.log`` record
|
|
:return: ``{"ok": True}``
|
|
"""
|
|
request.env["compositor.audit.log"].sudo().log_event(
|
|
kwargs.get("event_type", "app_crash"),
|
|
user_id=kwargs.get("user_id"),
|
|
session_id=kwargs.get("session_id"),
|
|
app_id=kwargs.get("app_id"),
|
|
app_instance_id=kwargs.get("app_instance_id"),
|
|
pid=kwargs.get("pid"),
|
|
exit_code=kwargs.get("exit_code"),
|
|
signal_received=kwargs.get("signal"),
|
|
detail=kwargs.get("detail"),
|
|
client_ip=kwargs.get("client_ip"),
|
|
)
|
|
return {"ok": True}
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# Internal: proxy call
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
def _call_proxy_launch(self, launch_config):
|
|
"""Send the launch request to the Python compositor proxy.
|
|
|
|
Returns the proxy's response dict, or raises if the proxy is
|
|
unreachable.
|
|
|
|
The actual implementation is in ``one_os_compositor_proxy``.
|
|
This stub raises ``NotImplementedError`` so the controller returns
|
|
a stub response that the OWL shell can develop against.
|
|
"""
|
|
raise NotImplementedError(
|
|
"Compositor proxy is not yet connected. "
|
|
"Install the one_os_compositor_proxy addon to enable app launching."
|
|
)
|
|
|
|
|
|
# Note: `fields` is imported at the top of this file.
|