diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c1d2197 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +FLASK_APP=app +FLASK_ENV=development +SECRET_KEY=replace-with-a-secure-random-key-in-prod +ZITADEL_CLIENT_ID=your-zitadel-client-id +ZITADEL_CLIENT_SECRET=your-zitadel-client-secret +ZITADEL_DISCOVERY_URL=https://id.vmlx.de/.well-known/openid-configuration +DATABASE_URL=sqlite:///app.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d582fc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +.env +instance/ +*.db +*.sqlite3 +.DS_Store +docs/_build/ +dist/ +*.egg-info/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6c6536d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN addgroup --system app && adduser --system --group app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN chown -R app:app /app +USER app + +EXPOSE 8000 + +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "wsgi:app"] diff --git a/README.md b/README.md index 813776f..2f1997c 100644 --- a/README.md +++ b/README.md @@ -1 +1,66 @@ -## oidc test +# OIDC Showcase – Flask + Zitadel RBAC + +Proof-of-Concept-Webapplikation zur Demonstration von feingranularem, dynamischem +Role-Based Access Control (RBAC) und Multi-Tenancy mit **Zitadel** als externem +Identity Provider (IdP) über OIDC. + +**Ziel:** Authentifizierung vollständig an Zitadel delegieren. Autorisierung, +Projektzuweisung und Datentrennung lokal über SQLite verwalten. + +--- + +## Technologie-Stack + +| Komponente | Technologie | +|---|---| +| Backend | Python Flask 2.x | +| OIDC-Client | Authlib | +| Datenbank | SQLite via SQLAlchemy | +| IdP | Zitadel (https://id.vmlx.de) | +| Produktion | Docker / gunicorn | + +## Rollen + +| Rolle | Zugriff | +|---|---| +| `Admin` | Vollzugriff auf alle Clients, Projekte und Nutzer | +| `Mitarbeiter` | Nur zugewiesene Projekte (N:M-Tabelle lokal) | +| `Kunde` | Nur zugewiesene Projekte des eigenen Clients | + +--- + +## Lokale Entwicklung + +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # Zitadel-Credentials eintragen (siehe ZITADEL-SETUP.md) +flask seed-db # Schema initialisieren + Testdaten einspielen +flask run # App startet auf http://localhost:5000 +``` + +## Docker / Produktion + +```bash +docker compose up --build +# App erreichbar auf http://localhost:8000 +``` + +Ein Basis-Image-Update löst durch `pull_policy: always` automatisch einen Rebuild aus. + +--- + +## Zitadel einrichten + +Anleitung zum Anlegen von Testnutzern, Rollen und OIDC-Credentials: +→ **[ZITADEL-SETUP.md](ZITADEL-SETUP.md)** + +## Dokumentation generieren + +```bash +pip install sphinx +sphinx-apidoc -o docs/source/ app/ +cd docs && make html +# Doku unter docs/_build/html/index.html +``` diff --git a/ZITADEL-SETUP.md b/ZITADEL-SETUP.md new file mode 100644 index 0000000..646061e --- /dev/null +++ b/ZITADEL-SETUP.md @@ -0,0 +1,138 @@ +# Zitadel-Einrichtung – Schritt-für-Schritt + +Zitadel-Instanz: **https://id.vmlx.de** + +Ziel dieser Anleitung: Ein OIDC-Projekt anlegen, drei Testrollen definieren, +drei Testnutzer erstellen, ihnen Rollen zuweisen und die Zitadel-User-IDs +(`sub`-Werte) für das DB-Seeding ermitteln. + +--- + +## Schritt 1 – Zitadel-Projekt anlegen + +1. Login unter https://id.vmlx.de +2. Linke Navigation → **Projekte** → **Neu anlegen** +3. Name: `oidc-showcase` → Speichern +4. Im Projekt-Detail unter **Einstellungen** folgende Optionen aktivieren: + - ☑ **Assert Roles on Authentication** (Rollen werden in Token geschrieben) + - ☑ **Check Authorization on Authentication** (Zugriff nur für zugewiesene Nutzer) + +--- + +## Schritt 2 – Rollen anlegen + +Im Projekt → Tab **Rollen** → drei Rollen anlegen: + +| Anzeigename | Schlüssel (Key) | +|---|---| +| Admin | `Admin` | +| Mitarbeiter | `Mitarbeiter` | +| Kunde | `Kunde` | + +> Der Schlüssel (Key) muss **exakt** so geschrieben sein – er erscheint so im Token-Claim. + +--- + +## Schritt 3 – OIDC-Anwendung anlegen + +Im Projekt → Tab **Anwendungen** → **Neu anlegen** + +| Feld | Wert | +|---|---| +| Name | `oidc-showcase-web` | +| Typ | **Web** | +| Auth-Methode | **Code** (Authorization Code mit Client Secret) | + +Nach dem Erstellen: +- **Client-ID** und **Client-Secret** notieren → kommen in die `.env` +- Unter **Redirect-URIs** eintragen: + - `http://localhost:5000/auth/callback` (Lokal) + - `https:///auth/callback` (Prod, falls zutreffend) +- Unter **Post-Logout-Redirect-URIs** eintragen: + - `http://localhost:5000/` + +--- + +## Schritt 4 – Testnutzer anlegen + +Navigation: **Nutzer** (oberste Ebene, nicht innerhalb eines Projekts) → **Neu anlegen** + +Drei Nutzer erstellen: + +| Name | E-Mail | Passwort | Zitadel-Rolle | +|---|---|---|---| +| Admin Test | admin@test.local | (eigenes PW) | Admin | +| Mitarbeiter Test | ma@test.local | (eigenes PW) | Mitarbeiter | +| Kunde Test | kunde@test.local | (eigenes PW) | Kunde | + +> Bei der Erstellung: **Passwort beim ersten Login ändern** deaktivieren empfohlen, damit Tests flüssig laufen. + +--- + +## Schritt 5 – Rollen an Nutzer vergeben + +Für jeden Nutzer: + +1. **Nutzer** → Nutzer auswählen → Tab **Autorisierungen** +2. → **Neu** → Projekt `oidc-showcase` wählen → entsprechende Rolle wählen → Speichern + +--- + +## Schritt 6 – User-IDs (sub) ermitteln + +Die User-ID (Zitadel-`sub`) ist die interne Zitadel-Nutzer-ID, **nicht** die E-Mail. + +**Methode A – Zitadel-Konsole:** +Nutzer auswählen → oben in der Detailansicht steht die **User-ID** (z. B. `236968807784841234`) + +**Methode B – Aus dem App-Log (nach Schritt 7):** +Nach dem ersten Login loggt die App in stdout: +``` +OIDC Login – sub: 236968807784841234 | email: admin@test.local | roles: ['Admin'] +``` + +Alle drei Sub-Werte notieren für den nächsten Schritt. + +--- + +## Schritt 7 – .env befüllen und DB seeden + +`.env` aus `.env.example` kopieren und befüllen: + +```bash +cp .env.example .env +``` + +```env +ZITADEL_CLIENT_ID= +ZITADEL_CLIENT_SECRET= +ZITADEL_DISCOVERY_URL=https://id.vmlx.de/.well-known/openid-configuration +SECRET_KEY= +``` + +Dann in `app/__init__.py` die Platzhalter in `seed_db()` mit den echten Sub-Werten ersetzen: + +```python +ADMIN_ID = "236968807784841234" # <-- echte Zitadel-User-ID +MITARBEITER_ID = "..." +KUNDE_ID = "..." +``` + +Anschließend seeden: + +```bash +source venv/bin/activate +flask seed-db +``` + +--- + +## Discovery-URL verifizieren + +```bash +curl https://id.vmlx.de/.well-known/openid-configuration | python3 -m json.tool +``` + +Wenn die Antwort ein JSON mit `issuer`, `authorization_endpoint` usw. ist, ist die URL korrekt. +Sollte die URL nicht funktionieren, alternative probieren: +`https://id.vmlx.de/oauth/v2/.well-known/openid-configuration` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3a3c47f --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,96 @@ +""" +Application Factory der OIDC Flask Showcase App. + +Initialisiert Flask, SQLAlchemy und Authlib. Registriert alle Blueprints +und stellt den CLI-Befehl ``flask seed-db`` bereit. + +:author: +:date: 2026-06-17 +:version: 0.1 +""" + +import logging +import click +from flask import Flask + +from .config import Config +from .database import db +from .auth import oauth, init_oauth + +logging.basicConfig(level=logging.INFO) + + +def create_app(config_class=Config): + app = Flask(__name__) + app.config.from_object(config_class) + + db.init_app(app) + init_oauth(app) + + from .routes import main + app.register_blueprint(main) + + _register_cli(app) + + return app + + +def _register_cli(app): + @app.cli.command("seed-db") + def seed_db(): + """Initialisiert das Schema und befüllt die DB mit Testdaten.""" + from datetime import datetime + from .models import ( + User, Client, Project, + ProjectMitarbeiterMapping, ProjectKundenMapping, Note, + ) + + db.drop_all() + db.create_all() + + # ------------------------------------------------------------------ + # PLATZHALTER – nach Zitadel-Setup durch echte sub-Werte ersetzen. + # Anleitung: ZITADEL-SETUP.md, Schritt 6 + # ------------------------------------------------------------------ + ADMIN_ID = "ZITADEL_SUB_ADMIN" + MITARBEITER_ID = "ZITADEL_SUB_MITARBEITER" + KUNDE_ID = "ZITADEL_SUB_KUNDE" + + db.session.add_all([ + User(zitadel_user_id=ADMIN_ID, email="admin@test.local", global_role="Admin"), + User(zitadel_user_id=MITARBEITER_ID, email="ma@test.local", global_role="Mitarbeiter"), + User(zitadel_user_id=KUNDE_ID, email="kunde@test.local", global_role="Kunde"), + ]) + + firma = Client(name="Firma Müller") + db.session.add(firma) + db.session.flush() + + p1 = Project(name="Akquise Landkreis", client_id=firma.id) + p2 = Project(name="Akquise Bundesstaat", client_id=firma.id) + p3 = Project(name="Spezielle Produktpalette", client_id=firma.id) + db.session.add_all([p1, p2, p3]) + db.session.flush() + + # Mitarbeiter → Projekte 1 + 2 (nicht 3 → Failure-Test-Button) + db.session.add_all([ + ProjectMitarbeiterMapping(user_id=MITARBEITER_ID, project_id=p1.id), + ProjectMitarbeiterMapping(user_id=MITARBEITER_ID, project_id=p2.id), + ]) + + # Kunde → Projekt 1 (nicht 2 + 3 → Failure-Test-Button) + db.session.add(ProjectKundenMapping(user_id=KUNDE_ID, project_id=p1.id)) + + db.session.add_all([ + Note(text="Erstkontakt aufgenommen.", project_id=p1.id, + user_id=MITARBEITER_ID, created_at=datetime.utcnow()), + Note(text="Angebot versendet.", project_id=p1.id, + user_id=MITARBEITER_ID, created_at=datetime.utcnow()), + Note(text="Folgetermin vereinbart.", project_id=p2.id, + user_id=MITARBEITER_ID, created_at=datetime.utcnow()), + ]) + + db.session.commit() + click.echo("Datenbank initialisiert und mit Testdaten befüllt.") + click.echo(f" → Platzhalter-IDs aktiv: {ADMIN_ID}, {MITARBEITER_ID}, {KUNDE_ID}") + click.echo(" → Echte IDs aus ZITADEL-SETUP.md Schritt 6 eintragen, dann erneut seeden.") diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..819ac66 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,56 @@ +""" +OIDC-Authentifizierung via Authlib und Zitadel. + +Konfiguriert den OAuth2-Client und stellt ``login_required`` sowie +``role_required`` als Route-Guard-Dekoratoren bereit. + +:author: +:date: 2026-06-17 +:version: 0.1 +""" + +import logging +from functools import wraps +from authlib.integrations.flask_client import OAuth +from flask import session, redirect, url_for + +logger = logging.getLogger(__name__) +oauth = OAuth() + + +def init_oauth(app): + oauth.init_app(app) + oauth.register( + name="zitadel", + server_metadata_url=app.config["ZITADEL_SERVER_METADATA_URL"], + client_id=app.config["ZITADEL_CLIENT_ID"], + client_secret=app.config["ZITADEL_CLIENT_SECRET"], + client_kwargs={"scope": "openid profile email"}, + ) + + +def get_current_user(): + return session.get("user") + + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("user"): + return redirect(url_for("main.login")) + return f(*args, **kwargs) + return decorated + + +def role_required(*roles): + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + user = session.get("user") + if not user: + return redirect(url_for("main.login")) + if user.get("role") not in roles: + return redirect(url_for("main.error")) + return f(*args, **kwargs) + return decorated + return decorator diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..858d1b9 --- /dev/null +++ b/app/config.py @@ -0,0 +1,27 @@ +""" +Konfiguration der Flask-Applikation. + +Liest alle Einstellungen aus Umgebungsvariablen (.env) nach dem 12-Factor-App-Prinzip. +ZITADEL_SERVER_METADATA_URL ist der von Authlib erwartete Config-Key für die +OIDC Discovery URL. + +:author: +:date: 2026-06-17 +:version: 0.1 +""" + +import os +from dotenv import load_dotenv + +load_dotenv() + + +class Config: + SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me") + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///app.db") + SQLALCHEMY_TRACK_MODIFICATIONS = False + + ZITADEL_CLIENT_ID = os.environ.get("ZITADEL_CLIENT_ID") + ZITADEL_CLIENT_SECRET = os.environ.get("ZITADEL_CLIENT_SECRET") + # Authlib liest ZITADEL_SERVER_METADATA_URL automatisch aus app.config + ZITADEL_SERVER_METADATA_URL = os.environ.get("ZITADEL_DISCOVERY_URL") diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..06ec07e --- /dev/null +++ b/app/database.py @@ -0,0 +1,11 @@ +""" +Datenbankinitialisierung mit Flask-SQLAlchemy. + +:author: +:date: 2026-06-17 +:version: 0.1 +""" + +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..290098a --- /dev/null +++ b/app/models.py @@ -0,0 +1,58 @@ +""" +SQLAlchemy-Datenmodelle für lokale RBAC- und Projektzuweisung. + +Alle Nutzeridentitäten stammen aus Zitadel (``zitadel_user_id`` = OIDC ``sub``-Claim). +Die lokalen Tabellen regeln nur die Autorisierung – nie die Authentifizierung. + +:author: +:date: 2026-06-17 +:version: 0.1 +""" + +from datetime import datetime +from .database import db + + +class User(db.Model): + __tablename__ = "users" + zitadel_user_id = db.Column(db.String(128), primary_key=True) + email = db.Column(db.String(256), nullable=False) + global_role = db.Column(db.String(64), nullable=False) + + +class Client(db.Model): + __tablename__ = "clients" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(256), nullable=False) + projects = db.relationship("Project", back_populates="client") + + +class Project(db.Model): + __tablename__ = "projects" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(256), nullable=False) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False) + client = db.relationship("Client", back_populates="projects") + notes = db.relationship("Note", back_populates="project", order_by="Note.created_at.desc()") + + +class ProjectMitarbeiterMapping(db.Model): + __tablename__ = "project_mitarbeiter" + user_id = db.Column(db.String(128), db.ForeignKey("users.zitadel_user_id"), primary_key=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), primary_key=True) + + +class ProjectKundenMapping(db.Model): + __tablename__ = "project_kunden" + user_id = db.Column(db.String(128), db.ForeignKey("users.zitadel_user_id"), primary_key=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), primary_key=True) + + +class Note(db.Model): + __tablename__ = "notes" + id = db.Column(db.Integer, primary_key=True) + text = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + user_id = db.Column(db.String(128), db.ForeignKey("users.zitadel_user_id"), nullable=False) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False) + project = db.relationship("Project", back_populates="notes") diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..dc47e9c --- /dev/null +++ b/app/routes.py @@ -0,0 +1,184 @@ +""" +Flask-Routen und Routing-Guards der OIDC-Showcase-App. + +Alle geschützten Routen prüfen (1) den Zitadel-Claim aus der Session und +(2) die lokale DB-Zuordnung. Jede Verletzung leitet auf /error um. + +:author: +:date: 2026-06-17 +:version: 0.1 +""" + +import logging +from urllib.parse import urlencode + +from flask import Blueprint, render_template, session, redirect, url_for + +from .auth import oauth, login_required, role_required, get_current_user +from .database import db +from .models import ( + User, Client, Project, + ProjectMitarbeiterMapping, ProjectKundenMapping, Note, +) + +logger = logging.getLogger(__name__) +main = Blueprint("main", __name__) + + +# --------------------------------------------------------------------------- +# Öffentliche Routen +# --------------------------------------------------------------------------- + +@main.route("/login") +def login(): + redirect_uri = url_for("main.auth_callback", _external=True) + return oauth.zitadel.authorize_redirect(redirect_uri) + + +@main.route("/auth/callback") +def auth_callback(): + token = oauth.zitadel.authorize_access_token() + + # Authlib parst den ID-Token und legt die Claims unter token["userinfo"] ab. + # Zitadel muss "Assert Roles on Authentication" aktiviert haben (ZITADEL-SETUP.md). + userinfo = token.get("userinfo") or {} + + sub = userinfo.get("sub") + email = userinfo.get("email", "") + + # Zitadel-Roles-Claim: {"Admin": {"": ""}, ...} + roles_claim = userinfo.get("urn:zitadel:iam:org:project:roles", {}) + + logger.info( + "OIDC Login – sub: %s | email: %s | roles: %s | full_userinfo: %s", + sub, email, list(roles_claim.keys()), dict(userinfo), + ) + + if not roles_claim: + logger.warning("Kein Roles-Claim im Token für sub=%s – Zugriff verweigert.", sub) + return redirect(url_for("main.error")) + + role = next(iter(roles_claim.keys())) + + user = db.session.get(User, sub) + if user: + user.email = email + user.global_role = role + else: + user = User(zitadel_user_id=sub, email=email, global_role=role) + db.session.add(user) + db.session.commit() + + session["user"] = {"sub": sub, "email": email, "role": role} + session["id_token"] = token.get("id_token") + + return redirect(url_for("main.index")) + + +@main.route("/logout") +def logout(): + id_token = session.pop("id_token", None) + session.clear() + + try: + metadata = oauth.zitadel.load_server_metadata() + end_session_endpoint = metadata.get("end_session_endpoint") + if end_session_endpoint: + params = {"post_logout_redirect_uri": url_for("main.index", _external=True)} + if id_token: + params["id_token_hint"] = id_token + return redirect(f"{end_session_endpoint}?{urlencode(params)}") + except Exception as exc: + logger.error("Logout: Fehler beim Laden der Server-Metadaten: %s", exc) + + return redirect(url_for("main.index")) + + +@main.route("/error") +def error(): + return render_template("login_error.html"), 403 + + +# --------------------------------------------------------------------------- +# Geschützte Routen +# --------------------------------------------------------------------------- + +@main.route("/") +@login_required +def index(): + user = get_current_user() + role = user.get("role") + sub = user.get("sub") + + accessible_projects = [] + forbidden_project = None + + if role == "Mitarbeiter": + mappings = ProjectMitarbeiterMapping.query.filter_by(user_id=sub).all() + accessible_ids = {m.project_id for m in mappings} + accessible_projects = Project.query.filter(Project.id.in_(accessible_ids)).all() + forbidden_project = Project.query.filter(Project.id.notin_(accessible_ids)).first() + + elif role == "Kunde": + mappings = ProjectKundenMapping.query.filter_by(user_id=sub).all() + accessible_ids = {m.project_id for m in mappings} + accessible_projects = Project.query.filter(Project.id.in_(accessible_ids)).all() + forbidden_project = Project.query.filter(Project.id.notin_(accessible_ids)).first() + + return render_template( + "index.html", + user=user, + projects=accessible_projects, + forbidden_project=forbidden_project, + ) + + +@main.route("/admin") +@role_required("Admin") +def admin(): + user = get_current_user() + return render_template( + "admin.html", + user=user, + clients=Client.query.all(), + projects=Project.query.all(), + users=User.query.all(), + ) + + +@main.route("/mitarbeiter") +@role_required("Mitarbeiter") +def mitarbeiter(): + user = get_current_user() + sub = user["sub"] + mappings = ProjectMitarbeiterMapping.query.filter_by(user_id=sub).all() + projects = Project.query.filter( + Project.id.in_({m.project_id for m in mappings}) + ).all() + return render_template("mitarbeiter.html", user=user, projects=projects) + + +@main.route("/projekt/") +@login_required +def projekt(project_id): + user = get_current_user() + role = user.get("role") + sub = user.get("sub") + + project = db.session.get(Project, project_id) + if not project: + return redirect(url_for("main.error")) + + if role == "Admin": + pass + elif role == "Mitarbeiter": + if not ProjectMitarbeiterMapping.query.filter_by(user_id=sub, project_id=project_id).first(): + return redirect(url_for("main.error")) + elif role == "Kunde": + if not ProjectKundenMapping.query.filter_by(user_id=sub, project_id=project_id).first(): + return redirect(url_for("main.error")) + else: + return redirect(url_for("main.error")) + + notes = Note.query.filter_by(project_id=project_id).order_by(Note.created_at.desc()).all() + return render_template("projekt.html", user=user, project=project, notes=notes) diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..39672af --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,90 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #f4f6f9; + color: #1a1a2e; + line-height: 1.6; +} + +/* Navigation */ +nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: .75rem 2rem; + background: #1a1a2e; + color: #fff; +} +.nav-brand { font-weight: 600; letter-spacing: .03em; } +.nav-user { display: flex; align-items: center; gap: 1.25rem; font-size: .9rem; } +nav a { color: #a8d8ea; text-decoration: none; } +nav a:hover { text-decoration: underline; } + +/* Layout */ +main { max-width: 960px; margin: 2rem auto; padding: 0 1.25rem; } +h1 { margin-bottom: .25rem; } +h2 { margin: 1.25rem 0 .6rem; font-size: 1.1rem; } +.subtitle { color: #555; margin-bottom: 1.75rem; font-size: .95rem; } + +/* Card grid */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1.25rem; + margin-top: 1.5rem; +} +.card { + background: #fff; + border: 1px solid #dde1e7; + border-radius: 8px; + padding: 1.25rem; +} +.card--warning { border-color: #e5a100; background: #fffbe6; } + +/* Buttons */ +.btn { + display: inline-block; + margin-top: .75rem; + padding: .45rem 1.2rem; + background: #1a1a2e; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + text-decoration: none; + font-size: .9rem; + transition: background .15s; +} +.btn:hover { background: #2d2d52; } +.btn--danger { background: #c0392b; } +.btn--danger:hover { background: #a93226; } +.btn:disabled { background: #aaa; cursor: not-allowed; } + +/* Tables */ +table { width: 100%; border-collapse: collapse; margin: .5rem 0 1rem; font-size: .9rem; } +th, td { padding: .5rem .75rem; text-align: left; border-bottom: 1px solid #e8eaed; } +th { background: #f0f2f5; font-weight: 600; } +tr:last-child td { border-bottom: none; } +code { font-size: .82rem; background: #f0f2f5; padding: .1rem .3rem; border-radius: 3px; } + +/* Project list */ +.project-list { list-style: none; margin: .5rem 0 1rem; } +.project-list li { padding: .45rem 0; border-bottom: 1px solid #e8eaed; } +.project-list li:last-child { border-bottom: none; } +.client-name { color: #777; font-size: .88rem; } + +/* Error page */ +.error-box { + text-align: center; + padding: 3rem 2rem; + background: #fff; + border-radius: 8px; + border: 1px solid #dde1e7; + max-width: 480px; + margin: 4rem auto; +} +.error-box h1 { color: #c0392b; margin-bottom: 1rem; } +.error-box p { color: #555; margin-bottom: 1.75rem; } + +section { margin-bottom: 2rem; } diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..460aef6 --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,71 @@ +{# + Datei: templates/admin.html + Autor: + Datum: 2026-06-17 + Zweck: Admin-Ansicht mit Übersicht aller Clients, Projekte und Nutzer. + Variablen: user, clients (list), projects (list), users (list) +#} +{% extends "base.html" %} + +{% block title %}Admin Cockpit{% endblock %} + +{% block content %} +

Admin Cockpit

+ +
+

Clients

+ + + + {% for c in clients %} + + {% else %} + + {% endfor %} + +
IDName
{{ c.id }}{{ c.name }}
Keine Clients vorhanden.
+
+ +
+

Projekte

+ + + + {% for p in projects %} + + + + + + {% else %} + + {% endfor %} + +
IDNameClient
{{ p.id }}{{ p.name }}{{ p.client.name }}
Keine Projekte vorhanden.
+
+ +
+

Nutzer

+ + + + {% for u in users %} + + + + + + {% else %} + + {% endfor %} + +
Zitadel-ID (sub)E-MailRolle
{{ u.zitadel_user_id }}{{ u.email }}{{ u.global_role }}
Keine Nutzer in der DB.
+
+ +
+

Aktionen

+ +
+ +

← Zurück zum Dashboard

+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..52995d5 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,29 @@ +{# + Datei: templates/base.html + Autor: + Datum: 2026-06-17 + Zweck: Basis-Layout für alle Seiten. Erwartet: session.user (optional). +#} + + + + + + {% block title %}OIDC Showcase{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..3b6ec98 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,56 @@ +{# + Datei: templates/index.html + Autor: + Datum: 2026-06-17 + Zweck: Dashboard mit Test-Matrix. Zeigt Kacheln je nach Rolle des eingeloggten Nutzers. + Variablen: user (dict), projects (list[Project]), forbidden_project (Project|None) +#} +{% extends "base.html" %} + +{% block title %}Dashboard{% endblock %} + +{% block content %} +

Dashboard

+

Angemeldet als {{ user.email }}  ·  Rolle: {{ user.role }}

+ +
+ + {% if user.role == "Admin" %} +
+

Admin Cockpit

+

Vollzugriff auf alle Clients, Projekte und Nutzer.

+ Öffnen +
+ {% endif %} + + {% if user.role == "Mitarbeiter" %} +
+

Mitarbeiter Cockpit

+

Deine zugewiesenen Projekte im Überblick.

+ Meine Projekte +
+ {% endif %} + + {% if projects %} +
+

Kundenprojekte

+ +
+ {% endif %} + + {% if forbidden_project %} +
+

⚠ Test: Unbefugter Zugriff

+

Simuliert einen Angriff: Zugriff auf ein nicht zugewiesenes Projekt.

+ + Zugriff auf „{{ forbidden_project.name }}" erzwingen + +
+ {% endif %} + +
+{% endblock %} diff --git a/app/templates/login_error.html b/app/templates/login_error.html new file mode 100644 index 0000000..edd058b --- /dev/null +++ b/app/templates/login_error.html @@ -0,0 +1,17 @@ +{# + Datei: templates/login_error.html + Autor: + Datum: 2026-06-17 + Zweck: Fehlerseite bei verweigertem Zugriff. Keine Variablen erwartet. +#} +{% extends "base.html" %} + +{% block title %}Zugriff verweigert{% endblock %} + +{% block content %} +
+

Zugriff verweigert

+

Bitte besuch uns später wieder, du bist im Moment nicht autorisiert.

+ +
+{% endblock %} diff --git a/app/templates/mitarbeiter.html b/app/templates/mitarbeiter.html new file mode 100644 index 0000000..4caea85 --- /dev/null +++ b/app/templates/mitarbeiter.html @@ -0,0 +1,28 @@ +{# + Datei: templates/mitarbeiter.html + Autor: + Datum: 2026-06-17 + Zweck: Projektübersicht für Mitarbeiter (nur zugewiesene Projekte). + Variablen: user, projects (list[Project]) +#} +{% extends "base.html" %} + +{% block title %}Meine Projekte{% endblock %} + +{% block content %} +

Mitarbeiter Cockpit

+

Deine zugewiesenen Projekte:

+ +
    + {% for p in projects %} +
  • + {{ p.name }} + – {{ p.client.name }} +
  • + {% else %} +
  • Keine Projekte zugewiesen.
  • + {% endfor %} +
+ +

← Zurück

+{% endblock %} diff --git a/app/templates/projekt.html b/app/templates/projekt.html new file mode 100644 index 0000000..cc266e8 --- /dev/null +++ b/app/templates/projekt.html @@ -0,0 +1,40 @@ +{# + Datei: templates/projekt.html + Autor: + Datum: 2026-06-17 + Zweck: Projekt-Cockpit mit Notizen-Log. Zugriff nur für zugewiesene Nutzer. + Variablen: user, project (Project), notes (list[Note]) +#} +{% extends "base.html" %} + +{% block title %}{{ project.name }}{% endblock %} + +{% block content %} +

{{ project.name }}

+

Client: {{ project.client.name }}

+ +
+

Korrespondenz / Notizen

+ {% if notes %} + + + + + + {% for n in notes %} + + + + + + {% endfor %} + +
DatumAutor (sub)Notiz
{{ n.created_at.strftime('%Y-%m-%d %H:%M') }}{{ n.user_id }}{{ n.text }}
+ + {% else %} +

Keine Notizen für dieses Projekt vorhanden.

+ {% endif %} +
+ +

← Zurück zum Dashboard

+{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2a73dbd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + web: + build: + context: . + # pull: true sorgt dafür, dass ein aktualisiertes Basis-Image einen Rebuild auslöst + pull_policy: always + env_file: + - .env + ports: + - "8000:8000" + volumes: + - db_data:/app/instance + restart: unless-stopped + +volumes: + db_data: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..4622834 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,14 @@ +# Sphinx-Konfiguration – wird nach jedem Meilenstein mit flask seed-db und +# sphinx-apidoc -o docs/source/ app/ aktualisiert. + +project = "OIDC Flask Showcase" +author = "Bjoern Runden" +release = "0.1" + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", +] + +exclude_patterns = ["_build"] +html_theme = "alabaster" diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..33c3554 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,21 @@ +OIDC Flask Showcase – Dokumentation +===================================== + +.. toctree:: + :maxdepth: 2 + :caption: Module: + +.. automodule:: app + :members: + +.. automodule:: app.config + :members: + +.. automodule:: app.models + :members: + +.. automodule:: app.auth + :members: + +.. automodule:: app.routes + :members: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4291545 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask==2.3.3 +Flask-SQLAlchemy==3.1.1 +authlib==1.3.1 +requests==2.31.0 +python-dotenv==1.0.0 +gunicorn==21.2.0 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..86e5c04 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,11 @@ +""" +WSGI-Einstiegspunkt für gunicorn (Produktion). + +:author: +:date: 2026-06-17 +:version: 0.1 +""" + +from app import create_app + +app = create_app()