logs.unturf.com/app.py
2025-01-22 18:22:03 -05:00

1334 lines
42 KiB
Python

###############################################################################
# app.py - PyraLogs Application with Enhanced Secret Management
# - Secrets are generated once, saved to the filesystem, and loaded thereafter
# - Different secrets are used for session signing and JWT signing
# - Includes previous enhancements for agent management and OpenAPI integration
###############################################################################
import os
import base64
import datetime
import random
import string
import bcrypt
import re
import uuid
import hashlib
import smtplib
import mimetypes
import json
import logging
import unicodedata
from email.mime.text import MIMEText
from pyramid.config import Configurator
from pyramid.view import view_config
from pyramid.response import Response
from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound
from pyramid.session import SignedCookieSessionFactory
from sqlalchemy import (
create_engine,
Column,
String,
DateTime,
Boolean,
Integer,
Index,
ForeignKey,
Text,
or_,
)
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import scoped_session, relationship
from sqlalchemy.pool import StaticPool
from waitress import serve
from pyramid.renderers import render_to_response
from pyramid.events import subscriber
from pyramid_jinja2 import IJinja2Environment
import transaction # Import transaction management
from zope.sqlalchemy import register # Import zope.sqlalchemy
import jwt # Import PyJWT library
from jwt import PyJWTError
################################################################################
# Set up logging
################################################################################
logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)
################################################################################
# Environment Variables and Defaults
################################################################################
# Application directory
APP_DIR = os.path.dirname(os.path.abspath(__file__))
# Data directory
DATA_DIR = os.path.join(APP_DIR, "data")
# Ensure DATA_DIR exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
# Paths to the secret files
PYRALOGS_SECRET_FILE = os.path.join(DATA_DIR, "pyralogs_secret.txt")
JWT_SECRET_FILE = os.path.join(DATA_DIR, "jwt_secret.txt")
def get_or_create_secret(env_var_name, secret_file_path):
"""
Retrieves the secret from an environment variable, or loads it from the
specified file. If neither is available, generates a new secret, saves
it to the file, and returns it.
"""
# Check environment variable
secret = os.environ.get(env_var_name, "")
if secret:
log.info(f"Using {env_var_name} from environment variable.")
return secret
# Check if the secret file exists
if os.path.exists(secret_file_path):
with open(secret_file_path, "r") as f:
secret = f.read().strip()
if secret:
log.info(f"Loaded {env_var_name} from {secret_file_path}")
return secret
else:
log.warning(f"{secret_file_path} is empty. Generating new secret.")
else:
log.info(f"{secret_file_path} does not exist. Generating new secret.")
# Generate a new secret
secret = "".join(random.choices(string.ascii_letters + string.digits, k=64))
# Save the secret to the file
with open(secret_file_path, "w") as f:
f.write(secret)
log.info(f"Generated and saved new {env_var_name} to {secret_file_path}")
return secret
# Retrieve or generate the secrets
pyralogs_secret = get_or_create_secret("PYRALOGS_SECRET", PYRALOGS_SECRET_FILE)
JWT_SECRET = get_or_create_secret("PYRALOGS_JWT_SECRET", JWT_SECRET_FILE)
JWT_ALGORITHM = "HS256"
# Database URL can be overridden by environment variable
default_main_db_url = f"sqlite:///{os.path.join(DATA_DIR, 'main.db')}"
DB_URL = os.environ.get("PYRALOGS_DB_URL", default_main_db_url)
# Host and port for the application
HOST = os.environ.get("PYRALOGS_HOST", "0.0.0.0")
PORT = int(os.environ.get("PYRALOGS_PORT", "6544"))
# SMTP host/port
smtp_host = os.environ.get("PYRALOGS_SMTP_HOST", "localhost")
smtp_port = int(os.environ.get("PYRALOGS_SMTP_PORT", "25"))
################################################################################
# Helper Functions
################################################################################
def slugify(text):
text = text.lower()
text = re.sub(r"\s+", "-", text)
text = re.sub(r"[^\w\-]", "", text)
return text
def get_gravatar_url(email, size=100):
email = email.strip().lower()
hash_code = hashlib.md5(email.encode("utf-8")).hexdigest()
return f"https://www.gravatar.com/avatar/{hash_code}?s={size}&d=identicon"
def send_email(to_email, subject, body, from_email=None):
if from_email is None:
from_email = os.environ.get("PYRALOGS_FROM_EMAIL", "master@master.unturf.com")
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = from_email
msg["To"] = to_email
try:
s = smtplib.SMTP(smtp_host, smtp_port)
s.sendmail(from_email, [to_email], msg.as_string())
s.quit()
except Exception as e:
log = logging.getLogger(__name__)
log.info("======= Email Sent =======")
log.info(f"To: {to_email}")
log.info(f"Subject: {subject}")
log.info(f"Body:\n{body}")
log.info("==========================")
log.error(f"Error sending email: {e}")
def admin_required(view_func):
def wrapper(request):
user = request.user
if not user or not user.is_admin:
return HTTPForbidden("You must be an admin to access this page.")
return view_func(request)
return wrapper
def get_current_user(request):
"""Return the current user (authenticated or guest) from session."""
user_id = request.session.get("user_id")
s = request.dbsession
if user_id:
# Try to get the user from the database
user = s.query(User).filter_by(id=user_id).first()
if user:
user.dbsession = s # Attach dbsession to user
return user
else:
# User ID in session does not exist in the database; remove it
del request.session["user_id"]
# No valid user in session; return None
return None
def get_user_namespace_role(request):
user = request.user
namespace = request.namespace
if not user or not namespace:
return None
s = request.dbsession
association = (
s.query(NamespaceUserAssociation)
.filter(
NamespaceUserAssociation.user_id == user.id,
NamespaceUserAssociation.namespace_id == namespace.id,
)
.first()
)
if association:
return association.role
else:
return None
def uuid_to_short_id(u):
"""Encode UUID to a URL-safe base64 string without padding."""
return base64.urlsafe_b64encode(u.bytes).decode("ascii").rstrip("=")
def short_id_to_uuid(sid):
"""Decode the short ID back to UUID, trying different padding lengths."""
for padding_length in range(6):
try:
padded = sid + ("=" * padding_length)
bytes_data = base64.urlsafe_b64decode(padded)
if len(bytes_data) == 16: # UUID is 16 bytes
return uuid.UUID(bytes=bytes_data)
except Exception:
continue
log.error(f"Failed to convert short_id {sid} to UUID after trying all paddings")
return None
def get_namespace_db_url(namespace_id):
"""Return the database URL for the namespace's SQLite database."""
db_file = os.path.join(DATA_DIR, f"namespace_{namespace_id}.db")
return f"sqlite:///{db_file}"
def slugify_filename(filename):
"""Sanitize and slugify filename to prevent path traversal."""
filename = os.path.basename(filename)
filename = re.sub(r"[^\w\.-]", "_", filename)
return filename or "file"
################################################################################
# Database Setup
################################################################################
log.debug(f"Using database URL: {DB_URL}") # For debugging
Base = declarative_base()
# Association class for Namespace <-> User (with roles)
class NamespaceUserAssociation(Base):
__tablename__ = "namespace_user_association"
namespace_id = Column(String, ForeignKey("namespaces.id"), primary_key=True)
user_id = Column(String, ForeignKey("users.id"), primary_key=True)
role = Column(String, nullable=False) # 'owner', 'editor', 'reader'
namespace = relationship("Namespace", back_populates="user_associations")
user = relationship("User", back_populates="namespace_associations")
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True) # UUID
short_id = Column(String, unique=True, nullable=False)
email = Column(String, unique=True, nullable=True)
username = Column(String, unique=True, nullable=False)
code_hash = Column(String, nullable=True) # bcrypt hash of code
code_expires = Column(DateTime, nullable=True) # time limit for code
is_verified = Column(Boolean, default=False)
enable_gravatar = Column(Boolean, default=False) # Gravatar support
is_admin = Column(Boolean, default=False) # Admin flag
# Namespaces the user is associated with
namespace_associations = relationship(
"NamespaceUserAssociation", back_populates="user"
)
namespaces = relationship(
"Namespace",
secondary="namespace_user_association",
back_populates="users",
)
# Attribute to hold dbsession in permission checks
dbsession = None
def __repr__(self):
return f"<User(username='{self.username}', email='{self.email}')>"
class Namespace(Base):
__tablename__ = "namespaces"
id = Column(String, primary_key=True) # UUID
short_id = Column(String, unique=True, nullable=False)
name = Column(String, unique=True, nullable=False)
is_public = Column(Boolean, default=False)
# Users associated with the namespace
user_associations = relationship(
"NamespaceUserAssociation", back_populates="namespace"
)
users = relationship(
"User",
secondary="namespace_user_association",
back_populates="namespaces",
)
# Agents associated with the namespace
agents = relationship("Agent", back_populates="namespace")
def __repr__(self):
return f"<Namespace(name='{self.name}', is_public={self.is_public})>"
class Agent(Base):
__tablename__ = "agents"
id = Column(String, primary_key=True) # UUID
name = Column(String, nullable=False)
namespace_id = Column(String, ForeignKey("namespaces.id"))
token_version = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
status = Column(String, default="active") # 'active' or 'revoked'
namespace = relationship("Namespace", back_populates="agents")
def __repr__(self):
return (
f"<Agent(name='{self.name}', namespace_id='{self.namespace_id}', "
f"status='{self.status}')>"
)
################################################################################
# Jinja2 Environment and Custom Filters
################################################################################
@subscriber(IJinja2Environment)
def add_jinja2_filters(event):
env = event.environment
# Add any custom Jinja2 filters here
################################################################################
# Request Methods
################################################################################
def get_namespace(request):
"""Get the namespace from the route parameter 'namespace_short_id'."""
namespace_short_id = request.matchdict.get("namespace_short_id")
if not namespace_short_id:
return None
s = request.dbsession
namespace = s.query(Namespace).filter_by(short_id=namespace_short_id).first()
return namespace
def get_namespace_dbsession(request):
"""Adds namespace_dbsession to request if namespace is set."""
namespace = request.namespace
if namespace:
namespace_dbsession = get_namespace_dbsession_by_namespace_id(
namespace.id, request
)
return namespace_dbsession
else:
return None # No namespace selected
def get_namespace_dbsession_by_namespace_id(namespace_id, request):
"""Helper function to get a namespace_dbsession for a given namespace_id."""
namespace_db_url = get_namespace_db_url(namespace_id)
db_file = os.path.join(DATA_DIR, f"namespace_{namespace_id}.db")
if not os.path.exists(db_file):
# Create the namespace database if it doesn't exist
engine = create_engine(
namespace_db_url,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
NamespaceBase.metadata.create_all(engine)
engine.dispose()
namespace_engine = create_engine(
namespace_db_url,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
NamespaceSessionFactory = sessionmaker(bind=namespace_engine)
namespace_dbsession = scoped_session(NamespaceSessionFactory)
register(namespace_dbsession) # Register with zope.sqlalchemy
# Attach cleanup callbacks
def cleanup(_request):
namespace_dbsession.remove()
namespace_engine.dispose()
request.add_finished_callback(cleanup)
return namespace_dbsession
def check_namespace_permission(user, namespace, required_role):
"""Check if the user has the required role in the namespace."""
if not namespace:
return False
s = user.dbsession
association = (
s.query(NamespaceUserAssociation)
.filter(
NamespaceUserAssociation.namespace_id == namespace.id,
NamespaceUserAssociation.user_id == user.id,
)
.first()
)
if not association:
if namespace.is_public and required_role == "reader":
return True
else:
return False
user_role = association.role
roles_hierarchy = {"owner": 3, "editor": 2, "reader": 1}
return roles_hierarchy.get(user_role, 0) >= roles_hierarchy.get(required_role, 0)
def owner_required(view_func):
def wrapper(request):
user = request.user
namespace = request.namespace
if not namespace:
return HTTPNotFound("Namespace not found.")
if not user or not check_namespace_permission(user, namespace, "owner"):
return HTTPForbidden("You must be an owner to access this page.")
return view_func(request)
return wrapper
def editor_required(view_func):
def wrapper(request):
user = request.user
namespace = request.namespace
if not namespace:
return HTTPNotFound("Namespace not found.")
if not user or not check_namespace_permission(user, namespace, "editor"):
return HTTPForbidden("You must be an editor to access this page.")
return view_func(request)
return wrapper
def reader_required(view_func):
def wrapper(request):
user = request.user
namespace = request.namespace
if not namespace:
return HTTPNotFound("Namespace not found.")
if not user or not check_namespace_permission(user, namespace, "reader"):
return HTTPForbidden("You do not have access to this namespace.")
return view_func(request)
return wrapper
################################################################################
# Namespace Database Models
################################################################################
NamespaceBase = declarative_base()
class LogEntry(NamespaceBase):
__tablename__ = "log_entries"
id = Column(Integer, primary_key=True, autoincrement=True)
timestamp = Column(DateTime, default=datetime.datetime.utcnow)
message = Column(Text, nullable=False)
level = Column(String, nullable=False)
log_metadata = Column(Text, nullable=True)
def __repr__(self):
return f"<LogEntry(id={self.id}, timestamp={self.timestamp})>"
################################################################################
# Routes and Views
################################################################################
@view_config(route_name="home", renderer="home.html.j2")
def home_view(request):
s = request.dbsession
# Get public namespaces
public_namespaces = s.query(Namespace).filter(Namespace.is_public == True).all()
user_namespaces = []
if request.user and request.user.is_verified:
# Get namespaces the user has access to along with their roles
user_namespaces = []
for association in request.user.namespace_associations:
ns = association.namespace
user_namespaces.append(
{
"name": ns.name,
"short_id": ns.short_id,
"role": association.role,
}
)
return {
"request": request,
"public_namespaces": public_namespaces,
"user_namespaces": user_namespaces,
}
################################################################################
# Authentication Views
################################################################################
@view_config(route_name="login", request_method="GET", renderer="login.html.j2")
def login_get_view(request):
return {"request": request}
@view_config(route_name="login", request_method="POST")
def login_post_view(request):
email = request.POST.get("email", "").strip().lower()
if not email:
return Response("Email required.", status=400)
session = request.dbsession
user = session.query(User).filter_by(email=email).first()
if not user:
# Generate UUID and short ID
user_uuid = uuid.uuid4()
user_id = str(user_uuid)
short_id = uuid_to_short_id(user_uuid)
# Create new user
user = User(
id=user_id,
short_id=short_id,
email=email,
username=email.split("@")[0],
is_verified=False,
)
session.add(user)
session.flush()
# Generate 6-digit code
code_str = f"{random.randint(0,999999):06d}"
code_hash = bcrypt.hashpw(code_str.encode("utf-8"), bcrypt.gensalt()).decode(
"utf-8"
)
user.code_hash = code_hash
user.code_expires = datetime.datetime.now() + datetime.timedelta(minutes=15)
user.is_verified = False
session.flush()
# Send code via email
email_body = f"Your verification code is: {code_str}"
send_email(user.email, "Your Verification Code", email_body)
# Store the email in the session for verification
request.session["login_email"] = email
return HTTPFound(location=request.route_url("verify"))
@view_config(route_name="verify", request_method="GET", renderer="verify.html.j2")
def verify_get_view(request):
return {"request": request}
@view_config(route_name="verify", request_method="POST")
def verify_post_view(request):
code_entered = request.POST.get("code", "").strip()
if not code_entered or len(code_entered) != 6:
return Response("Invalid code.", status=400)
email = request.session.get("login_email")
if not email:
return Response(
"No email found in session. Please start the login process again.",
status=400,
)
s = request.dbsession
user = (
s.query(User)
.filter(
User.email == email,
User.code_expires > datetime.datetime.now(),
User.code_hash != None,
)
.first()
)
if not user:
return Response("Code not found or expired.", status=400)
if not bcrypt.checkpw(code_entered.encode("utf-8"), user.code_hash.encode("utf-8")):
return Response("Invalid code.", status=400)
user.is_verified = True
user.code_hash = None
user.code_expires = None
s.flush()
# Remove the email from the session
del request.session["login_email"]
request.session["user_id"] = user.id
# Authentication cookies are used for other routes.
return HTTPFound(location=request.route_url("home"))
@view_config(route_name="logout", request_method="POST", require_csrf=True)
def logout_view(request):
request.session.invalidate()
return HTTPFound(location=request.route_url("home"))
################################################################################
# JWT Helper Functions
################################################################################
def generate_jwt_token(agent):
"""Generate a JWT for the given agent without an expiration time."""
payload = {
"agent_id": agent.id,
"agent_name": agent.name,
"namespace_id": agent.namespace_id,
"token_version": agent.token_version,
"iat": datetime.datetime.utcnow(),
# "exp": datetime.datetime.utcnow() + expires_delta, # Removed to make token not expire
}
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
return token
def verify_jwt_token(token):
"""Verify the JWT and return the payload if valid."""
try:
payload = jwt.decode(
token,
JWT_SECRET,
algorithms=[JWT_ALGORITHM],
options={"verify_exp": False}, # Disable expiration verification
)
return payload
except PyJWTError:
return None
################################################################################
# Profile and Namespace Management
################################################################################
@view_config(route_name="profile", request_method="GET", renderer="profile.html.j2")
def profile_get_view(request):
user = request.user
if not user:
return Response("You must be logged in to access your profile.", status=403)
gravatar_url = get_gravatar_url(user.email) if user.enable_gravatar else ""
# Get namespaces where the user is an owner
owner_namespaces = []
for association in user.namespace_associations:
if association.role == "owner":
owner_namespaces.append(association.namespace)
return {
"request": request,
"user": user,
"gravatar_url": gravatar_url,
"owner_namespaces": owner_namespaces,
}
@view_config(route_name="profile", request_method="POST")
def profile_post_view(request):
if not request.user:
return Response("You must be logged in to update your profile.", status=403)
if not request.user.is_verified:
return Response(
"This account is in guest mode, log in to update your profile.",
status=403,
)
s = request.dbsession
enable_gravatar = request.POST.get("enable_gravatar") == "on"
request.user.enable_gravatar = enable_gravatar
# Handle username update if provided
new_username = request.POST.get("new_username", "").strip()
if new_username:
# Check if the new username is already taken
existing = s.query(User).filter(User.username == new_username).first()
if existing and existing.id != request.user.id:
return Response("Username is already in use.", status=400)
request.user.username = new_username
s.flush()
return HTTPFound(location=request.route_url("profile"))
@view_config(
route_name="create_namespace",
request_method="GET",
renderer="create_namespace.html.j2",
)
def create_namespace_get_view(request):
if not request.user or not request.user.is_verified:
return Response("You must be logged in to create a namespace.", status=403)
return {"request": request}
@view_config(route_name="create_namespace", request_method="POST")
def create_namespace_post_view(request):
user = request.user
if not user or not user.is_verified:
return Response("You must be logged in to create a namespace.", status=403)
name = request.POST.get("name", "").strip()
if not name:
return Response("Namespace name is required.", status=400)
s = request.dbsession
existing_namespace = s.query(Namespace).filter(Namespace.name == name).first()
if existing_namespace:
return Response("Namespace name already exists.", status=400)
# Generate UUID and short ID for the namespace
namespace_uuid = uuid.uuid4()
namespace_id = str(namespace_uuid)
namespace_short_id = uuid_to_short_id(namespace_uuid)
is_public = request.POST.get("is_public") == "on"
namespace = Namespace(
id=namespace_id,
short_id=namespace_short_id,
name=name,
is_public=is_public,
)
s.add(namespace)
# Add the user as an owner
association = NamespaceUserAssociation(
namespace=namespace,
user=user,
role="owner",
)
s.add(association)
s.flush()
# Create namespace database
namespace_db_url = get_namespace_db_url(namespace.id)
engine = create_engine(
namespace_db_url,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
NamespaceBase.metadata.create_all(engine)
engine.dispose()
return HTTPFound(
location=request.route_url(
"manage_namespace", namespace_short_id=namespace.short_id
)
)
@view_config(route_name="manage_namespace", renderer="manage_namespace.html.j2")
@editor_required
def manage_namespace_view(request):
namespace = request.namespace
s = request.dbsession
# Get users and their roles in the namespace
associations = (
s.query(NamespaceUserAssociation)
.filter(NamespaceUserAssociation.namespace_id == namespace.id)
.all()
)
users = []
for association in associations:
user = association.user
users.append({"user": user, "role": association.role})
# Get agents associated with the namespace (only active agents)
agents = (
s.query(Agent)
.filter(Agent.namespace_id == namespace.id, Agent.status == "active")
.all()
)
return {
"request": request,
"namespace": namespace,
"users": users,
"agents": agents,
}
@view_config(route_name="change_member_role", request_method="POST")
@owner_required
def change_member_role_view(request):
s = request.dbsession
namespace = request.namespace
current_user = request.user
user_id = request.POST.get("user_id")
new_role = request.POST.get("role")
if not user_id or not new_role:
return Response("User ID and new role are required.", status=400)
if new_role not in ["owner", "editor", "reader"]:
return Response("Invalid role.", status=400)
# Prevent owners from changing their own role
if user_id == current_user.id:
return Response("Owners cannot change their own role.", status=400)
# Ensure the user is a member of the namespace
association = (
s.query(NamespaceUserAssociation)
.filter(
NamespaceUserAssociation.namespace_id == namespace.id,
NamespaceUserAssociation.user_id == user_id,
)
.first()
)
if not association:
return Response("User is not a member of this namespace.", status=400)
# Update the user's role
association.role = new_role
s.flush()
request.session.flash(f"User's role has been updated to {new_role}.")
return HTTPFound(
location=request.route_url(
"manage_namespace", namespace_short_id=namespace.short_id
)
)
@view_config(route_name="update_namespace", request_method="POST")
@owner_required
def update_namespace_view(request):
namespace = request.namespace
s = request.dbsession
# Update namespace properties
is_public = request.POST.get("is_public") == "on"
namespace.is_public = is_public
s.flush()
return HTTPFound(
location=request.route_url(
"manage_namespace", namespace_short_id=namespace.short_id
)
)
@view_config(route_name="invite_user", request_method="POST")
@owner_required
def invite_user_view(request):
namespace = request.namespace
s = request.dbsession
email = request.POST.get("email", "").strip().lower()
role = request.POST.get("role", "").strip().lower()
if role not in ["owner", "editor", "reader"]:
return Response("Invalid role.", status=400)
# Find or create the user
user = s.query(User).filter(User.email == email).first()
if not user:
user_uuid = uuid.uuid4()
user_id = str(user_uuid)
short_id = uuid_to_short_id(user_uuid)
user = User(
id=user_id,
short_id=short_id,
email=email,
username=email.split("@")[0],
is_verified=False,
)
s.add(user)
s.flush()
# Check if the user already has an association with the namespace
existing_association = (
s.query(NamespaceUserAssociation)
.filter(
NamespaceUserAssociation.namespace_id == namespace.id,
NamespaceUserAssociation.user_id == user.id,
)
.first()
)
if existing_association:
# Update the role if the user is already associated
existing_association.role = role
else:
# Create a new association
association = NamespaceUserAssociation(
namespace=namespace,
user=user,
role=role,
)
s.add(association)
s.flush()
request.session.flash(f"{user.email} was invited as {role} role.")
# Send invitation email
email_body = (
f"You have been invited as a {role} to namespace "
f"'{namespace.name}'. Please log in to access it."
)
send_email(user.email, "Namespace Invitation", email_body)
return HTTPFound(
location=request.route_url(
"manage_namespace", namespace_short_id=namespace.short_id
)
)
@view_config(route_name="remove_user", request_method="POST")
@owner_required
def remove_user_view(request):
s = request.dbsession
namespace = request.namespace
current_user = request.user # The owner initiating the removal
# Get the user ID to remove from the POST data
user_id_to_remove = request.POST.get("user_id")
if not user_id_to_remove:
return Response("User ID is required.", status=400)
# Ensure that the user exists and is a member of the namespace
user_to_remove = s.query(User).filter(User.id == user_id_to_remove).first()
if not user_to_remove:
return Response("User not found.", status=404)
# Prevent owners from removing themselves
if user_to_remove.id == current_user.id:
return Response("Owners cannot remove themselves.", status=400)
# Check if the user to remove is a member of the namespace
association = (
s.query(NamespaceUserAssociation)
.filter(
NamespaceUserAssociation.namespace_id == namespace.id,
NamespaceUserAssociation.user_id == user_id_to_remove,
)
.first()
)
if not association:
return Response("User is not a member of this namespace.", status=400)
# Remove the association
s.delete(association)
s.flush()
# Provide a success message
request.session.flash(
f"User '{user_to_remove.username}' has been removed from the namespace."
)
return HTTPFound(
location=request.route_url(
"manage_namespace", namespace_short_id=namespace.short_id
)
)
@view_config(
route_name="generate_agent_jwt",
request_method="POST",
renderer="display_agent_jwt.html.j2",
)
@editor_required
def generate_agent_jwt_view(request):
namespace = request.namespace
s = request.dbsession
agent_name = request.POST.get("agent_name", "").strip()
if not agent_name:
return Response("Agent name is required.", status=400)
# Check if an agent with the same name exists in the namespace
agent = (
s.query(Agent)
.filter(
Agent.name == agent_name,
Agent.namespace_id == namespace.id,
)
.first()
)
if agent:
# Agent exists, increment token_version and regenerate JWT
agent.token_version += 1
agent.status = "active" # Ensure the agent is active
s.flush()
jwt_token = generate_jwt_token(agent)
message = (
f"A new JWT has been generated for existing agent '{agent.name}'. "
"Any previous tokens have been revoked."
)
else:
# Generate UUID for the new agent
agent_uuid = uuid.uuid4()
agent_id = str(agent_uuid)
# Create a new agent entry
agent = Agent(
id=agent_id,
name=agent_name,
namespace_id=namespace.id,
token_version=0,
status="active",
)
s.add(agent)
s.flush()
# Generate JWT for the agent including the namespace_id
jwt_token = generate_jwt_token(agent)
message = f"A new agent '{agent.name}' has been created."
return {
"request": request,
"namespace": namespace,
"agent_name": agent_name,
"jwt_token": jwt_token,
"message": message,
}
@view_config(route_name="revoke_agent", request_method="POST")
@editor_required
def revoke_agent_view(request):
namespace = request.namespace
s = request.dbsession
agent_id = request.POST.get("agent_id")
if not agent_id:
return Response("Agent ID is required.", status=400)
# Get the agent
agent = (
s.query(Agent)
.filter(
Agent.id == agent_id,
Agent.namespace_id == namespace.id,
)
.first()
)
if not agent:
return Response("Agent not found.", status=404)
# Set the agent's status to 'revoked' to hide it from the dashboard
agent.status = "revoked"
s.flush()
request.session.flash(f"Agent '{agent.name}' has been revoked.")
return HTTPFound(
location=request.route_url(
"manage_namespace", namespace_short_id=namespace.short_id
)
)
################################################################################
# Logging Views
################################################################################
@view_config(route_name="log_webhook", request_method="POST")
def log_webhook_view(request):
s = request.dbsession
# Attempt authentication via JWT
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return Response("Missing or invalid Authorization header.", status=401)
token = auth_header[len("Bearer ") :].strip()
payload = verify_jwt_token(token)
if not payload:
return Response("Invalid or expired token.", status=401)
# Get agent from the JWT
agent_id = payload.get("agent_id")
if not agent_id:
return Response("Agent ID not specified in token.", status=400)
agent = s.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
return Response("Agent not found.", status=404)
# Check agent status
if agent.status != "active":
return Response("Agent is revoked or inactive.", status=401)
# Check token_version
if payload.get("token_version") != agent.token_version:
return Response("Token has been revoked.", status=401)
# Get namespace from the agent
namespace = s.query(Namespace).filter(Namespace.id == agent.namespace_id).first()
if not namespace:
return Response("Namespace not found.", status=404)
request.namespace = namespace
# No need to issue a new token since tokens do not expire due to time
# Access namespace_dbsession
namespace_dbsession = request.namespace_dbsession
# Parse payload
try:
json_payload = request.json_body
except json.decoder.JSONDecodeError:
return Response("Invalid JSON payload.", status=400)
message = json_payload.get("message", "")
if not message:
return Response("Log message is required.", status=400)
level = json_payload.get("level", "INFO") # Default to 'INFO' if not provided
meta_data = json_payload.get("metadata", None)
if meta_data is not None:
meta_data = json.dumps(meta_data)
# Create log entry
log_entry = LogEntry(
message=message,
level=level,
log_metadata=meta_data,
)
namespace_dbsession.add(log_entry)
namespace_dbsession.flush()
return Response("Log entry created.", status=201)
@view_config(route_name="view_namespace_logs", renderer="view_namespace_logs.html.j2")
def view_namespace_logs_view(request):
namespace = request.namespace
if not namespace:
return HTTPNotFound("Namespace not found.")
namespace_dbsession = request.namespace_dbsession
# Check if the namespace is public
if namespace.is_public:
# Allow access to anyone
pass # Proceed to retrieve logs
else:
# Namespace is private
# Check if user is authenticated
user = request.user
if not user:
# User is not authenticated
return HTTPForbidden("You must be logged in to access this namespace.")
# Check if user has at least 'reader' role in this namespace
if not check_namespace_permission(user, namespace, required_role="reader"):
return HTTPForbidden("You do not have access to this namespace.")
# Get the search query from the request parameters
search_query = request.params.get("q", "").strip()
# Start building the query
query = namespace_dbsession.query(LogEntry)
if search_query:
# Create a search pattern for case-insensitive search
search_pattern = f"%{search_query}%"
query = query.filter(
or_(
LogEntry.message.ilike(search_pattern),
LogEntry.level.ilike(search_pattern),
LogEntry.log_metadata.ilike(search_pattern),
)
)
logs = query.order_by(LogEntry.timestamp.desc()).all()
return {
"request": request,
"namespace": namespace,
"logs": logs,
"search_query": search_query,
}
################################################################################
# Main
################################################################################
def main(*config, **settings):
# Configure logging
logging.basicConfig(level=logging.INFO)
# Set up the session factory
session_factory = SignedCookieSessionFactory(
secret=pyralogs_secret,
hashalg="sha512",
timeout=31104000, # Approx. one year in seconds
max_age=31104000, # Set Max-Age attribute on cookie
reissue_time=15552000, # Approx. six months
samesite=None, # Allows cross-site requests if needed
httponly=True, # Helps mitigate XSS attacks
secure=False, # Set to True if using HTTPS
)
if not settings:
settings = {}
settings["sqlalchemy.url"] = DB_URL
config = Configurator(settings=settings, session_factory=session_factory)
config.include("pyramid_jinja2")
# Include pyramid_tm for transaction management
config.include("pyramid_tm")
config.include("pyramid_openapi3")
config.pyramid_openapi3_spec("openapi.yaml", route="/openapi.yaml")
config.pyramid_openapi3_add_explorer(route="/docs")
# Add .html.j2 extension for Jinja2 templates
config.add_jinja2_renderer(".j2")
config.add_jinja2_search_path("templates", name=".j2")
# The Jinja2 filters are added via the event subscriber above
# Set up SQLAlchemy
engine = create_engine(
DB_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
session_factory_ = sessionmaker(bind=engine)
Base.metadata.bind = engine
DBSession = scoped_session(session_factory_)
# Register with zope.sqlalchemy
register(DBSession)
# Provide dbsession to requests
def dbsession(request):
return DBSession
config.add_request_method(dbsession, "dbsession", reify=True)
# Add user, namespace, user_namespace_role to requests
config.add_request_method(get_current_user, "user", reify=True)
config.add_request_method(get_namespace, "namespace", reify=True)
config.add_request_method(
get_user_namespace_role, "user_namespace_role", reify=True
)
# Add namespace_dbsession to requests if namespace is set
config.add_request_method(
get_namespace_dbsession, "namespace_dbsession", reify=True
)
# Routes
config.add_route("home", "/")
# Auth for cookie sessions.
config.add_route("login", "/auth/login")
config.add_route("verify", "/auth/verify")
config.add_route("logout", "/auth/logout")
config.add_route("profile", "/auth/profile")
# Namespace Management
config.add_route("create_namespace", "/namespace/create")
config.add_route("manage_namespace", "/namespace/{namespace_short_id}/manage")
config.add_route("update_namespace", "/namespace/{namespace_short_id}/update")
config.add_route("invite_user", "/namespace/{namespace_short_id}/invite")
config.add_route("remove_user", "/namespace/{namespace_short_id}/remove_user")
config.add_route(
"change_member_role", "/namespace/{namespace_short_id}/change_member_role"
)
# Routes to generate JWTs for agents without access to an email mailbox.
config.add_route("revoke_agent", "/namespace/{namespace_short_id}/revoke_agent")
config.add_route(
"generate_agent_jwt", "/namespace/{namespace_short_id}/generate_agent_jwt"
)
# Logging
config.add_route("log_webhook", "/webhook")
config.add_route("view_namespace_logs", "/namespace/{namespace_short_id}/logs")
config.scan()
return config.make_wsgi_app()
# Expose the WSGI application callable for uwsgi
uwsgi_app = main({})
if __name__ == "__main__":
app = main({})
log.info(f"Serving on http://{HOST}:{PORT}")
serve(app, host=HOST, port=PORT)