working with revocations
modified: README.rst modified: app.py modified: templates/manage_namespace.html.j2 modified: test_agent.sh
This commit is contained in:
parent
bed3607168
commit
a8b0ee313d
4 changed files with 319 additions and 404 deletions
335
app.py
335
app.py
|
|
@ -1,5 +1,6 @@
|
|||
###############################################################################
|
||||
# app.py - PyraLogs Application with JWT-based Authentication and Refresh Mechanism
|
||||
# app.py - PyraLogs Application with Agent-Specific JWT Authentication and Revocation
|
||||
# Users do not have token_version as only agents use JWTs
|
||||
###############################################################################
|
||||
import os
|
||||
import base64
|
||||
|
|
@ -149,36 +150,8 @@ def get_current_user(request):
|
|||
# User ID in session does not exist in the database; remove it
|
||||
del request.session["user_id"]
|
||||
|
||||
# No valid user in session; create a guest user
|
||||
# Generate a unique guest username
|
||||
while True:
|
||||
suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
||||
guest_username = f"Guest-{suffix}"
|
||||
existing_user = s.query(User).filter_by(username=guest_username).first()
|
||||
if not existing_user:
|
||||
break # Unique username found
|
||||
|
||||
# Generate UUID and short ID
|
||||
user_uuid = uuid.uuid4()
|
||||
new_user_id = str(user_uuid)
|
||||
short_id = uuid_to_short_id(user_uuid)
|
||||
|
||||
# Create a new guest user
|
||||
guest_user = User(
|
||||
id=new_user_id,
|
||||
short_id=short_id,
|
||||
email=None, # Guests don't have an email
|
||||
username=guest_username,
|
||||
is_verified=False,
|
||||
)
|
||||
s.add(guest_user)
|
||||
s.flush() # Use flush instead of commit in pyramid_tm
|
||||
|
||||
# Store the user ID in the session
|
||||
request.session["user_id"] = guest_user.id
|
||||
|
||||
guest_user.dbsession = s # Attach dbsession to user
|
||||
return guest_user
|
||||
# No valid user in session; return None
|
||||
return None
|
||||
|
||||
|
||||
def uuid_to_short_id(u):
|
||||
|
|
@ -226,12 +199,12 @@ Base = declarative_base()
|
|||
|
||||
# Association table for Namespace <-> User (with roles)
|
||||
namespace_user_association = Table(
|
||||
'namespace_user_association',
|
||||
"namespace_user_association",
|
||||
Base.metadata,
|
||||
Column('namespace_id', String, ForeignKey('namespaces.id')),
|
||||
Column('user_id', String, ForeignKey('users.id')),
|
||||
Column('role', String, nullable=False), # 'owner', 'editor', 'reader'
|
||||
Index('ix_namespace_user', 'namespace_id', 'user_id', unique=True)
|
||||
Column("namespace_id", String, ForeignKey("namespaces.id")),
|
||||
Column("user_id", String, ForeignKey("users.id")),
|
||||
Column("role", String, nullable=False), # 'owner', 'editor', 'reader'
|
||||
Index("ix_namespace_user", "namespace_id", "user_id", unique=True),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -239,8 +212,8 @@ 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) # Guests don't have an email.
|
||||
username = Column(String, unique=True, nullable=False) # user-chosen handle
|
||||
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)
|
||||
|
|
@ -275,10 +248,27 @@ class Namespace(Base):
|
|||
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)
|
||||
|
||||
namespace = relationship("Namespace", back_populates="agents")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Agent(name='{self.name}', namespace_id='{self.namespace_id}')>"
|
||||
|
||||
|
||||
################################################################################
|
||||
# Jinja2 Environment and Custom Filters
|
||||
################################################################################
|
||||
|
|
@ -430,11 +420,33 @@ class LogEntry(NamespaceBase):
|
|||
@view_config(route_name="home", renderer="home.html.j2")
|
||||
def home_view(request):
|
||||
s = request.dbsession
|
||||
# Show public namespaces
|
||||
# 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 ns in request.user.namespaces:
|
||||
result = s.execute(
|
||||
namespace_user_association.select().where(
|
||||
namespace_user_association.c.namespace_id == ns.id,
|
||||
namespace_user_association.c.user_id == request.user.id,
|
||||
)
|
||||
).first()
|
||||
if result:
|
||||
user_namespaces.append(
|
||||
{
|
||||
"name": ns.name,
|
||||
"short_id": ns.short_id,
|
||||
"role": result.role,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"request": request,
|
||||
"public_namespaces": public_namespaces,
|
||||
"user_namespaces": user_namespaces,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -553,14 +565,15 @@ def logout_view(request):
|
|||
# JWT Helper Functions
|
||||
################################################################################
|
||||
|
||||
def generate_jwt_token(user, expires_delta=datetime.timedelta(days=365)):
|
||||
"""Generate a JWT for the given user."""
|
||||
def generate_jwt_token(agent):
|
||||
"""Generate a JWT for the given agent without an expiration time."""
|
||||
payload = {
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
# Set 'iat' to issue time and 'nbf' to tomorrow to enforce daily refresh
|
||||
"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,
|
||||
# "exp": datetime.datetime.utcnow() + expires_delta, # Removed to make token not expire
|
||||
}
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
return token
|
||||
|
|
@ -569,30 +582,17 @@ def generate_jwt_token(user, expires_delta=datetime.timedelta(days=365)):
|
|||
def verify_jwt_token(token):
|
||||
"""Verify the JWT and return the payload if valid."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
JWT_SECRET,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
options={"verify_exp": False}, # Disable expiration verification
|
||||
)
|
||||
return payload
|
||||
except PyJWTError:
|
||||
return None
|
||||
|
||||
|
||||
def issue_new_jwt_if_needed(old_token, user):
|
||||
"""Issue a new JWT if the token was last used on a previous day."""
|
||||
payload = verify_jwt_token(old_token)
|
||||
if not payload:
|
||||
return None # Invalid token
|
||||
last_used = datetime.datetime.utcfromtimestamp(payload.get("last_used", 0))
|
||||
now = datetime.datetime.utcnow()
|
||||
|
||||
if last_used.date() < now.date():
|
||||
# Update 'last_used' to now and issue a new token
|
||||
new_payload = payload.copy()
|
||||
new_payload["last_used"] = now.timestamp()
|
||||
token = jwt.encode(new_payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
return token
|
||||
else:
|
||||
return None # No need to issue a new token
|
||||
|
||||
|
||||
################################################################################
|
||||
# Profile and Namespace Management
|
||||
################################################################################
|
||||
|
|
@ -615,7 +615,7 @@ def profile_get_view(request):
|
|||
namespace_user_association.c.user_id == user.id,
|
||||
)
|
||||
).first()
|
||||
if result and result.role == 'owner':
|
||||
if result and result.role == "owner":
|
||||
owner_namespaces.append(ns)
|
||||
|
||||
return {
|
||||
|
|
@ -651,7 +651,11 @@ def profile_post_view(request):
|
|||
return HTTPFound(location=request.route_url("profile"))
|
||||
|
||||
|
||||
@view_config(route_name="create_namespace", request_method="GET", renderer="create_namespace.html.j2")
|
||||
@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)
|
||||
|
|
@ -694,7 +698,7 @@ def create_namespace_post_view(request):
|
|||
namespace_user_association.insert().values(
|
||||
namespace_id=namespace.id,
|
||||
user_id=user.id,
|
||||
role='owner',
|
||||
role="owner",
|
||||
)
|
||||
)
|
||||
s.flush()
|
||||
|
|
@ -702,12 +706,18 @@ def create_namespace_post_view(request):
|
|||
# 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
|
||||
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))
|
||||
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")
|
||||
|
|
@ -727,15 +737,21 @@ def manage_namespace_view(request):
|
|||
users = []
|
||||
for ur in user_roles:
|
||||
u = s.query(User).filter(User.id == ur.user_id).first()
|
||||
users.append({
|
||||
'user': u,
|
||||
'role': ur.role,
|
||||
})
|
||||
users.append(
|
||||
{
|
||||
"user": u,
|
||||
"role": ur.role,
|
||||
}
|
||||
)
|
||||
|
||||
# Get agents associated with the namespace
|
||||
agents = s.query(Agent).filter(Agent.namespace_id == namespace.id).all()
|
||||
|
||||
return {
|
||||
"request": request,
|
||||
"namespace": namespace,
|
||||
"users": users,
|
||||
"agents": agents,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -750,7 +766,11 @@ def update_namespace_view(request):
|
|||
namespace.is_public = is_public
|
||||
s.flush()
|
||||
|
||||
return HTTPFound(location=request.route_url("manage_namespace", namespace_short_id=namespace.short_id))
|
||||
return HTTPFound(
|
||||
location=request.route_url(
|
||||
"manage_namespace", namespace_short_id=namespace.short_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@view_config(route_name="invite_user", request_method="POST")
|
||||
|
|
@ -761,7 +781,7 @@ def invite_user_view(request):
|
|||
|
||||
email = request.POST.get("email", "").strip().lower()
|
||||
role = request.POST.get("role", "").strip().lower()
|
||||
if role not in ['owner', 'editor', 'reader']:
|
||||
if role not in ["owner", "editor", "reader"]:
|
||||
return Response("Invalid role.", status=400)
|
||||
|
||||
# Find or create the user
|
||||
|
|
@ -803,47 +823,44 @@ def invite_user_view(request):
|
|||
email_body = f"You have been invited as a {role} to namespace '{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))
|
||||
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")
|
||||
@view_config(
|
||||
route_name="generate_agent_jwt",
|
||||
request_method="POST",
|
||||
renderer="display_agent_jwt.html.j2",
|
||||
)
|
||||
@owner_required
|
||||
def generate_agent_jwt_view(request):
|
||||
namespace = request.namespace
|
||||
user = request.user
|
||||
s = request.dbsession
|
||||
|
||||
agent_name = request.POST.get("agent_name", "").strip()
|
||||
if not agent_name:
|
||||
return Response("Agent name is required.", status=400)
|
||||
|
||||
# Create a user entry for the agent without an email
|
||||
# Generate UUID for the agent
|
||||
agent_uuid = uuid.uuid4()
|
||||
agent_id = str(agent_uuid)
|
||||
agent_short_id = uuid_to_short_id(agent_uuid)
|
||||
|
||||
agent_user = User(
|
||||
# Create an agent entry
|
||||
agent = Agent(
|
||||
id=agent_id,
|
||||
short_id=agent_short_id,
|
||||
email=None,
|
||||
username=agent_name,
|
||||
is_verified=True, # Agents are verified
|
||||
name=agent_name,
|
||||
namespace_id=namespace.id,
|
||||
token_version=0,
|
||||
)
|
||||
s = request.dbsession
|
||||
s.add(agent_user)
|
||||
|
||||
s.add(agent)
|
||||
s.flush()
|
||||
|
||||
# Assign the agent to the namespace with the appropriate role
|
||||
s.execute(
|
||||
namespace_user_association.insert().values(
|
||||
namespace_id=namespace.id,
|
||||
user_id=agent_user.id,
|
||||
role='editor', # Agents can be given 'editor' role
|
||||
)
|
||||
)
|
||||
s.flush()
|
||||
|
||||
# Generate JWT for the agent
|
||||
jwt_token = generate_jwt_token(agent_user)
|
||||
# Generate JWT for the agent including the namespace_id
|
||||
jwt_token = generate_jwt_token(agent)
|
||||
|
||||
return {
|
||||
"request": request,
|
||||
|
|
@ -852,6 +869,38 @@ def generate_agent_jwt_view(request):
|
|||
"jwt_token": jwt_token,
|
||||
}
|
||||
|
||||
|
||||
@view_config(route_name="revoke_agent", request_method="POST")
|
||||
@owner_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)
|
||||
|
||||
# Increment the agent's token_version to revoke its token
|
||||
agent.token_version += 1
|
||||
s.flush()
|
||||
request.session.flash(f"Access revoked for agent '{agent.name}'.")
|
||||
|
||||
return HTTPFound(
|
||||
location=request.route_url(
|
||||
"manage_namespace", namespace_short_id=namespace.short_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
################################################################################
|
||||
# Logging Views
|
||||
################################################################################
|
||||
|
|
@ -865,47 +914,48 @@ def log_webhook_view(request):
|
|||
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()
|
||||
token = auth_header[len("Bearer ") :].strip()
|
||||
payload = verify_jwt_token(token)
|
||||
if not payload:
|
||||
return Response("Invalid or expired token.", status=401)
|
||||
|
||||
# Get user and check permissions
|
||||
user = s.query(User).filter(User.id == payload["user_id"]).first()
|
||||
if not user:
|
||||
return Response("User not found.", status=404)
|
||||
request.user = user
|
||||
user.dbsession = s # Attach dbsession to user
|
||||
# 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)
|
||||
|
||||
# Issue a new JWT if needed and include it in the response
|
||||
new_token = issue_new_jwt_if_needed(token, user)
|
||||
if new_token:
|
||||
request.response.headers['X-New-JWT'] = new_token
|
||||
# Check token_version
|
||||
if payload.get("token_version") != agent.token_version:
|
||||
return Response("Token has been revoked.", status=401)
|
||||
|
||||
# Get namespace from header or parameter
|
||||
namespace_short_id = request.headers.get('X-Namespace') or request.params.get('namespace')
|
||||
if not namespace_short_id:
|
||||
return Response("Namespace not specified.", status=400)
|
||||
namespace = s.query(Namespace).filter(Namespace.short_id == namespace_short_id).first()
|
||||
# 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
|
||||
|
||||
# Check if user has sufficient permissions
|
||||
if not check_namespace_permission(user, namespace, 'editor'):
|
||||
return Response("You do not have permission to write to this namespace.", status=403)
|
||||
|
||||
request.namespace = namespace # Attach namespace to request
|
||||
# No need to issue a new token since tokens do not expire due to time
|
||||
|
||||
# Access namespace_dbsession
|
||||
namespace_dbsession = request.namespace_dbsession
|
||||
|
||||
payload = request.json_body
|
||||
message = payload.get('message', '')
|
||||
level = payload.get('level', 'INFO')
|
||||
meta_data = payload.get('metadata', None)
|
||||
if meta_data:
|
||||
# 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,
|
||||
|
|
@ -917,13 +967,19 @@ def log_webhook_view(request):
|
|||
return Response("Log entry created.", status=201)
|
||||
|
||||
|
||||
@view_config(route_name="view_namespace_logs", renderer="view_namespace_logs.html.j2")
|
||||
@view_config(
|
||||
route_name="view_namespace_logs", renderer="view_namespace_logs.html.j2"
|
||||
)
|
||||
@reader_required
|
||||
def view_namespace_logs_view(request):
|
||||
namespace = request.namespace
|
||||
|
||||
namespace_dbsession = request.namespace_dbsession
|
||||
logs = namespace_dbsession.query(LogEntry).order_by(LogEntry.timestamp.desc()).all()
|
||||
logs = (
|
||||
namespace_dbsession.query(LogEntry)
|
||||
.order_by(LogEntry.timestamp.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"request": request,
|
||||
|
|
@ -969,11 +1025,13 @@ def main(global_config=None, **settings):
|
|||
|
||||
# Set up SQLAlchemy
|
||||
engine = create_engine(
|
||||
DB_URL, connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||
DB_URL,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
session_factory_ = sessionmaker(bind=engine)
|
||||
Base.metadata.bind = engine
|
||||
#Base.metadata.create_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
DBSession = scoped_session(session_factory_)
|
||||
register(DBSession) # Register with zope.sqlalchemy
|
||||
|
|
@ -991,7 +1049,9 @@ def main(global_config=None, **settings):
|
|||
config.add_request_method(get_current_namespace, "namespace", reify=True)
|
||||
|
||||
# Add namespace_dbsession to requests if namespace is set
|
||||
config.add_request_method(add_namespace_dbsession, "namespace_dbsession", reify=True)
|
||||
config.add_request_method(
|
||||
add_namespace_dbsession, "namespace_dbsession", reify=True
|
||||
)
|
||||
|
||||
# Routes
|
||||
config.add_route("home", "/")
|
||||
|
|
@ -1004,15 +1064,24 @@ def main(global_config=None, **settings):
|
|||
|
||||
# Namespace Management
|
||||
config.add_route("create_namespace", "/namespace/create")
|
||||
config.add_route("manage_namespace", "/namespace/{namespace_short_id}/manage")
|
||||
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(
|
||||
"revoke_agent", "/namespace/{namespace_short_id}/revoke_agent"
|
||||
)
|
||||
# Route to generate JWTs for agents
|
||||
config.add_route("generate_agent_jwt", "/namespace/{namespace_short_id}/generate_agent_jwt")
|
||||
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.add_route(
|
||||
"view_namespace_logs", "/namespace/{namespace_short_id}/logs"
|
||||
)
|
||||
|
||||
config.scan()
|
||||
return config.make_wsgi_app()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue