mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 13:35:12 +09:00
The cross-cutting backbone the rest of the #33 agency-scale milestone needs, landed as a contained, narrow-only foundation rather than a 36-route rewrite. - Nullable workspace_id FK on applications + servers (Alembic 015 off 014) with an idempotent raw-SQL backfill: create a Default workspace, attach every workspace-less row, and make every existing user a member so nobody loses visibility once scoping is activated. Also creates the workspace_id indexes that upgraded installs would otherwise lack. - Centralized scope helper in WorkspaceService: ensure_default_workspace, resolve_workspace_id (validates a requested workspace; membership-checked with admin bypass; rejects deactivated accounts on the workspace path), and scope_query. - Opt-in + narrow-only: a request filters by workspace only when it carries an X-Workspace-Id header / ?workspace_id=. scope_query INTERSECTS the workspace filter with the existing per-user ownership filter, so it can only narrow visibility, never broaden it — with no context the behavior is byte-identical to before (admin->all, owner->own, servers global). This closes the footgun where the backfill would otherwise let a non-admin enumerate all resources via the Default workspace. - Wired into apps.py + servers.py (list + create): replaces the duplicated ownership branch and stamps new resources with the active-or-default workspace. Deferred to follow-on slices: broad route adoption, per-site ACL, the workspace<->User role reconciliation, server-side branding, and a frontend workspace selector. Verified: 10 new pytest tests (scope-narrowing, a can't-broaden-access regression, deactivated-user gate, raw-SQL backfill against real SQLite + idempotency), full suite 120 passed, a real end-to-end alembic upgrade to 015 on a fresh DB (columns + indexes), and a 2-lens adversarial review (3 findings, all fixed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
89 lines
4.1 KiB
Python
89 lines
4.1 KiB
Python
from datetime import datetime
|
|
from app import db
|
|
|
|
|
|
class Application(db.Model):
|
|
__tablename__ = 'applications'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(100), nullable=False)
|
|
app_type = db.Column(db.String(50), nullable=False) # 'php', 'wordpress', 'flask', 'django', 'docker', 'static'
|
|
status = db.Column(db.String(20), default='stopped') # 'running', 'stopped', 'error', 'deploying'
|
|
|
|
# Configuration
|
|
php_version = db.Column(db.String(10), nullable=True) # '8.0', '8.1', '8.2', '8.3'
|
|
python_version = db.Column(db.String(10), nullable=True) # '3.9', '3.10', '3.11', '3.12'
|
|
port = db.Column(db.Integer, nullable=True)
|
|
root_path = db.Column(db.String(500), nullable=True)
|
|
|
|
# Docker specific
|
|
docker_image = db.Column(db.String(200), nullable=True)
|
|
container_id = db.Column(db.String(100), nullable=True)
|
|
|
|
# Private URL feature
|
|
private_slug = db.Column(db.String(50), unique=True, nullable=True, index=True)
|
|
private_url_enabled = db.Column(db.Boolean, default=False)
|
|
|
|
# Environment linking
|
|
environment_type = db.Column(db.String(20), default='standalone') # 'production', 'development', 'staging', 'standalone'
|
|
linked_app_id = db.Column(db.Integer, db.ForeignKey('applications.id'), nullable=True)
|
|
shared_config = db.Column(db.Text, nullable=True) # JSON string for shared resources
|
|
|
|
# Metadata
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
last_deployed_at = db.Column(db.DateTime, nullable=True)
|
|
|
|
# Foreign keys
|
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
|
server_id = db.Column(db.String(36), db.ForeignKey('servers.id'), nullable=True, index=True)
|
|
# Workspace scoping (#33). Nullable: existing rows are backfilled to a default
|
|
# workspace by migration 015; new rows are stamped on create.
|
|
workspace_id = db.Column(db.Integer, db.ForeignKey('workspaces.id'), nullable=True, index=True)
|
|
|
|
# Relationships
|
|
# Use 'subquery' to eagerly load domains in a single query, avoiding N+1
|
|
domains = db.relationship('Domain', backref='application', lazy='subquery', cascade='all, delete-orphan')
|
|
linked_app = db.relationship('Application', remote_side=[id], backref='linked_from', foreign_keys=[linked_app_id])
|
|
server = db.relationship('Server', backref=db.backref('applications', lazy='dynamic'))
|
|
|
|
def to_dict(self, include_linked=False):
|
|
import json
|
|
result = {
|
|
'id': self.id,
|
|
'name': self.name,
|
|
'app_type': self.app_type,
|
|
'status': self.status,
|
|
'php_version': self.php_version,
|
|
'python_version': self.python_version,
|
|
'port': self.port,
|
|
'root_path': self.root_path,
|
|
'docker_image': self.docker_image,
|
|
'container_id': self.container_id,
|
|
'private_slug': self.private_slug,
|
|
'private_url_enabled': self.private_url_enabled,
|
|
'environment_type': self.environment_type,
|
|
'linked_app_id': self.linked_app_id,
|
|
'shared_config': json.loads(self.shared_config) if self.shared_config else None,
|
|
'has_linked_app': self.linked_app_id is not None,
|
|
'created_at': self.created_at.isoformat(),
|
|
'updated_at': self.updated_at.isoformat(),
|
|
'last_deployed_at': self.last_deployed_at.isoformat() if self.last_deployed_at else None,
|
|
'user_id': self.user_id,
|
|
'server_id': self.server_id,
|
|
'workspace_id': self.workspace_id,
|
|
'server_name': self.server.name if self.server else 'Local server',
|
|
'domains': [d.to_dict() for d in self.domains]
|
|
}
|
|
if include_linked and self.linked_app:
|
|
result['linked_app'] = {
|
|
'id': self.linked_app.id,
|
|
'name': self.linked_app.name,
|
|
'environment_type': self.linked_app.environment_type,
|
|
'status': self.linked_app.status
|
|
}
|
|
return result
|
|
|
|
def __repr__(self):
|
|
return f'<Application {self.name}>'
|