|
| 1 | +# Beacon Admin & Authentication System Design |
| 2 | + |
| 3 | +## Problem |
| 4 | + |
| 5 | +Beacon has no user management, no authentication, and no authorization. The LiveAdmin is unprotected. There's no Beacon-level admin for managing the platform. The site-level admin can't distinguish between admins, editors, and viewers. |
| 6 | + |
| 7 | +## Authentication: OpenID Connect |
| 8 | + |
| 9 | +Beacon uses **OpenID Connect** (via the `openid_connect` hex package) as its default authentication method. This delegates login, password management, MFA, and session lifecycle to an external identity provider (Google, Auth0, Okta, Keycloak, Azure AD, etc.). |
| 10 | + |
| 11 | +### Auth Modes |
| 12 | + |
| 13 | +**OIDC Mode** (default for production): One or more OIDC providers configured. Login redirects to the provider's authorization endpoint. On callback, Beacon matches the authenticated email against pre-provisioned `beacon_users` records. |
| 14 | + |
| 15 | +**Dev Mode** (development/test only): Simple email + password login without OIDC. Allows local development without configuring an identity provider. Passwords stored in `beacon_users` via bcrypt. **Disabled in production.** |
| 16 | + |
| 17 | +### Key Design Decisions |
| 18 | + |
| 19 | +- **Pre-provisioned users only.** OIDC login does NOT auto-create Beacon users. An admin must create the user in Beacon first. If no matching `beacon_users` record exists for the authenticated email, login is rejected with "Account not found." |
| 20 | +- **Multiple OIDC providers.** Beacon supports a list of providers. The login page shows a button per provider. Users are matched by email across providers. |
| 21 | +- **Email is the identity key.** A user authenticated via Google as `jane@company.com` maps to the same Beacon user as one authenticated via Azure AD as `jane@company.com`. |
| 22 | + |
| 23 | +### Configuration |
| 24 | + |
| 25 | +```elixir |
| 26 | +config :beacon, :auth, |
| 27 | + # Production: OIDC providers |
| 28 | + providers: [ |
| 29 | + google: [ |
| 30 | + discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration", |
| 31 | + client_id: System.get_env("GOOGLE_CLIENT_ID"), |
| 32 | + client_secret: System.get_env("GOOGLE_CLIENT_SECRET"), |
| 33 | + redirect_uri: "https://mysite.com/admin/auth/google/callback", |
| 34 | + response_type: "code", |
| 35 | + scope: "openid email profile" |
| 36 | + ], |
| 37 | + azure_ad: [ |
| 38 | + discovery_document_uri: "https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration", |
| 39 | + client_id: System.get_env("AZURE_CLIENT_ID"), |
| 40 | + client_secret: System.get_env("AZURE_CLIENT_SECRET"), |
| 41 | + redirect_uri: "https://mysite.com/admin/auth/azure_ad/callback", |
| 42 | + response_type: "code", |
| 43 | + scope: "openid email profile" |
| 44 | + ] |
| 45 | + ], |
| 46 | + # Dev mode: enable password login (MUST be false in production) |
| 47 | + dev_mode: Mix.env() != :prod, |
| 48 | + # Session settings |
| 49 | + session_signing_salt: "beacon_auth", |
| 50 | + session_max_age: 86400 * 30 # 30 days |
| 51 | + |
| 52 | +# openid_connect library config (referenced by Beacon) |
| 53 | +config :openid_connect, :providers, |
| 54 | + google: [ |
| 55 | + discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration", |
| 56 | + client_id: System.get_env("GOOGLE_CLIENT_ID"), |
| 57 | + client_secret: System.get_env("GOOGLE_CLIENT_SECRET"), |
| 58 | + response_type: "code", |
| 59 | + scope: "openid email profile" |
| 60 | + ] |
| 61 | +``` |
| 62 | + |
| 63 | +### OIDC Flow |
| 64 | + |
| 65 | +``` |
| 66 | +1. User visits /admin → not authenticated → redirect to login page |
| 67 | +2. Login page shows buttons: "Sign in with Google", "Sign in with Azure AD" |
| 68 | +3. User clicks provider → redirect to provider's authorization URL |
| 69 | +4. Provider authenticates user → redirects back to /admin/auth/:provider/callback |
| 70 | +5. Beacon exchanges code for tokens via openid_connect library |
| 71 | +6. Beacon extracts email from ID token claims |
| 72 | +7. Beacon looks up email in beacon_users table |
| 73 | +8. If found → create session, redirect to admin dashboard |
| 74 | +9. If not found → show "Account not found. Contact your administrator." |
| 75 | +``` |
| 76 | + |
| 77 | +### Dev Mode Flow |
| 78 | + |
| 79 | +``` |
| 80 | +1. User visits /admin → not authenticated → redirect to login page |
| 81 | +2. Login page shows email + password form (no OIDC buttons) |
| 82 | +3. User submits credentials |
| 83 | +4. Beacon verifies password against beacon_users.hashed_password |
| 84 | +5. If valid → create session, redirect to admin dashboard |
| 85 | +``` |
| 86 | + |
| 87 | +## Authorization: Role Hierarchy |
| 88 | + |
| 89 | +| Role | Scope | Capabilities | |
| 90 | +|------|-------|-------------| |
| 91 | +| **Super Admin** | Platform-wide | Manage sites, global template types, global settings, assign any role, manage all site content | |
| 92 | +| **Site Admin** | One or more sites | Full control over assigned sites. Assign Editor/Viewer roles for their sites. | |
| 93 | +| **Site Editor** | One or more sites | Create/edit/publish pages, manage content. Cannot change settings or template types. | |
| 94 | +| **Site Viewer** | One or more sites | Read-only admin access. Cannot modify anything. | |
| 95 | + |
| 96 | +## Data Model |
| 97 | + |
| 98 | +### Migration V013 |
| 99 | + |
| 100 | +```sql |
| 101 | +CREATE TABLE beacon_users ( |
| 102 | + id BINARY_ID PRIMARY KEY, |
| 103 | + email TEXT NOT NULL UNIQUE, |
| 104 | + name TEXT, |
| 105 | + hashed_password TEXT, -- Only used in dev mode |
| 106 | + avatar_url TEXT, -- From OIDC profile claims |
| 107 | + last_login_at TIMESTAMPTZ, |
| 108 | + last_login_provider TEXT, -- "google", "azure_ad", "dev" |
| 109 | + inserted_at TIMESTAMPTZ, |
| 110 | + updated_at TIMESTAMPTZ |
| 111 | +); |
| 112 | + |
| 113 | +CREATE TABLE beacon_user_sessions ( |
| 114 | + id BINARY_ID PRIMARY KEY, |
| 115 | + user_id BINARY_ID NOT NULL REFERENCES beacon_users(id) ON DELETE CASCADE, |
| 116 | + token BYTEA NOT NULL UNIQUE, |
| 117 | + inserted_at TIMESTAMPTZ NOT NULL |
| 118 | +); |
| 119 | + |
| 120 | +CREATE INDEX ON beacon_user_sessions (token); |
| 121 | + |
| 122 | +CREATE TABLE beacon_user_roles ( |
| 123 | + id BINARY_ID PRIMARY KEY, |
| 124 | + user_id BINARY_ID NOT NULL REFERENCES beacon_users(id) ON DELETE CASCADE, |
| 125 | + role TEXT NOT NULL, -- "super_admin", "site_admin", "site_editor", "site_viewer" |
| 126 | + site TEXT, -- NULL for super_admin, site atom string for site-scoped |
| 127 | + inserted_at TIMESTAMPTZ, |
| 128 | + UNIQUE(user_id, role, site) |
| 129 | +); |
| 130 | +``` |
| 131 | + |
| 132 | +### Schemas |
| 133 | + |
| 134 | +**`Beacon.Auth.User`** |
| 135 | +- Fields: email, name, hashed_password (optional), avatar_url, last_login_at, last_login_provider |
| 136 | +- Changeset: required email, unique email, password hashing (dev mode only) |
| 137 | + |
| 138 | +**`Beacon.Auth.UserSession`** |
| 139 | +- Fields: user_id, token |
| 140 | +- Token is a random 32-byte binary, stored as a cookie |
| 141 | + |
| 142 | +**`Beacon.Auth.UserRole`** |
| 143 | +- Fields: user_id, role, site |
| 144 | +- Validates role is one of: super_admin, site_admin, site_editor, site_viewer |
| 145 | +- super_admin roles must have site=nil |
| 146 | +- Site-scoped roles must have a non-nil site |
| 147 | + |
| 148 | +### Context Module: `Beacon.Auth` |
| 149 | + |
| 150 | +```elixir |
| 151 | +# User management |
| 152 | +Beacon.Auth.create_user(attrs) # Create a pre-provisioned user |
| 153 | +Beacon.Auth.update_user(user, attrs) # Update user profile |
| 154 | +Beacon.Auth.delete_user(user) # Delete user and all roles |
| 155 | +Beacon.Auth.list_users(opts) # List all users |
| 156 | +Beacon.Auth.get_user_by_email(email) # Look up by email |
| 157 | + |
| 158 | +# Dev mode password |
| 159 | +Beacon.Auth.set_password(user, password) # Set password for dev mode |
| 160 | +Beacon.Auth.verify_password(user, password) # Verify password |
| 161 | + |
| 162 | +# Session management |
| 163 | +Beacon.Auth.create_session(user) # Creates session token, returns token |
| 164 | +Beacon.Auth.get_user_by_session_token(token) # Look up user from session |
| 165 | +Beacon.Auth.delete_session(token) # Logout |
| 166 | + |
| 167 | +# Role management |
| 168 | +Beacon.Auth.assign_role(user, role, site \\ nil) |
| 169 | +Beacon.Auth.revoke_role(user, role, site \\ nil) |
| 170 | +Beacon.Auth.list_roles(user) |
| 171 | +Beacon.Auth.has_role?(user, role, site \\ nil) |
| 172 | +Beacon.Auth.authorize!(user, action, site) # Raises if unauthorized |
| 173 | + |
| 174 | +# OIDC |
| 175 | +Beacon.Auth.authenticate_oidc(provider, claims) # Match OIDC claims to user |
| 176 | +``` |
| 177 | + |
| 178 | +## Beacon Admin Interface |
| 179 | + |
| 180 | +### URL Structure |
| 181 | + |
| 182 | +``` |
| 183 | +/admin/auth/login — Login page (OIDC buttons + dev mode form) |
| 184 | +/admin/auth/:provider/callback — OIDC callback |
| 185 | +/admin/auth/logout — Logout |
| 186 | +
|
| 187 | +/admin/beacon/ — Beacon dashboard (super_admin only) |
| 188 | +/admin/beacon/sites — Manage sites |
| 189 | +/admin/beacon/template_types — Global template types |
| 190 | +/admin/beacon/settings — Global settings |
| 191 | +/admin/beacon/users — User management + role assignment |
| 192 | +
|
| 193 | +/admin/:site/pages — Site-scoped (existing LiveAdmin) |
| 194 | +/admin/:site/seo — Site-scoped |
| 195 | +... |
| 196 | +``` |
| 197 | + |
| 198 | +### Beacon Admin Pages (Super Admin Only) |
| 199 | + |
| 200 | +**Dashboard** — Overview of all sites, user counts, recent activity |
| 201 | + |
| 202 | +**Sites** — List configured sites with page counts, endpoints, prefixes |
| 203 | + |
| 204 | +**Global Template Types** — Same as existing template_type_manager but with `site: nil` |
| 205 | + |
| 206 | +**Global Settings** — AI crawler policy defaults, default meta tags, default OG image |
| 207 | + |
| 208 | +**Users** — CRUD for pre-provisioned users: |
| 209 | +- Create: email, name (password field only in dev mode) |
| 210 | +- Edit: name, avatar |
| 211 | +- Role assignment: multi-select roles with site picker for site-scoped roles |
| 212 | +- Deactivate/delete |
| 213 | + |
| 214 | +### Auth Integration in LiveAdmin |
| 215 | + |
| 216 | +**New Plugs:** |
| 217 | + |
| 218 | +`Beacon.Auth.Plug.RequireAuth` — Checks session cookie, loads user, assigns to conn. Redirects to login if not authenticated. |
| 219 | + |
| 220 | +`Beacon.Auth.Plug.RequireRole` — Checks user's roles against the requested scope. Returns 403 if insufficient permissions. |
| 221 | + |
| 222 | +**LiveAdmin Router Changes:** |
| 223 | + |
| 224 | +```elixir |
| 225 | +# In the host app's router |
| 226 | +beacon_live_admin "/admin", |
| 227 | + auth: true # Enables auth plugs in the admin pipeline |
| 228 | +``` |
| 229 | + |
| 230 | +When `auth: true`, the admin pipeline includes: |
| 231 | +1. `Beacon.Auth.Plug.RequireAuth` — redirect to login if no session |
| 232 | +2. `Beacon.Auth.Plug.LoadRoles` — load user's roles into assigns |
| 233 | +3. Route-level checks: Beacon admin pages require super_admin |
| 234 | +4. Site pages check site-scoped roles |
| 235 | + |
| 236 | +### OIDC Controller |
| 237 | + |
| 238 | +**`Beacon.Auth.OIDCController`** — Handles the OAuth2/OIDC redirect flow: |
| 239 | + |
| 240 | +```elixir |
| 241 | +# GET /admin/auth/:provider — Redirect to OIDC provider |
| 242 | +def authorize(conn, %{"provider" => provider}) |
| 243 | + |
| 244 | +# GET /admin/auth/:provider/callback — Handle OIDC callback |
| 245 | +def callback(conn, %{"provider" => provider, "code" => code}) |
| 246 | +``` |
| 247 | + |
| 248 | +Uses the `openid_connect` library to: |
| 249 | +1. Build authorization URL |
| 250 | +2. Exchange code for tokens |
| 251 | +3. Verify ID token |
| 252 | +4. Extract email from claims |
| 253 | +5. Match to beacon_users |
| 254 | + |
| 255 | +## Implementation Phases |
| 256 | + |
| 257 | +### Phase 1: Data Layer + Auth Context |
| 258 | +- Migration V013 (users, sessions, roles tables) |
| 259 | +- User, UserSession, UserRole schemas |
| 260 | +- Beacon.Auth context with all functions |
| 261 | +- bcrypt_elixir dependency for dev mode passwords |
| 262 | +- openid_connect dependency |
| 263 | + |
| 264 | +### Phase 2: OIDC Flow + Session Management |
| 265 | +- OIDCController (authorize + callback) |
| 266 | +- Session plug (cookie-based, token lookup) |
| 267 | +- Login LiveView (provider buttons + dev mode form) |
| 268 | +- Logout controller |
| 269 | + |
| 270 | +### Phase 3: Authorization Plugs |
| 271 | +- RequireAuth plug |
| 272 | +- RequireRole plug |
| 273 | +- Integration into LiveAdmin router pipeline |
| 274 | + |
| 275 | +### Phase 4: Beacon Admin UI |
| 276 | +- Dashboard, Sites, Global Template Types, Global Settings, Users pages |
| 277 | +- Route mounting at /admin/beacon/ prefix |
| 278 | +- Super admin navigation |
| 279 | + |
| 280 | +### Phase 5: Site Admin Role Enforcement |
| 281 | +- Existing LiveAdmin pages get role checks |
| 282 | +- UI adaptations for different roles (hide buttons for insufficient permissions) |
| 283 | +- Read-only mode for viewers |
| 284 | + |
| 285 | +### Phase 6: First User Bootstrap |
| 286 | +- Mix task: `mix beacon.create_admin --email admin@example.com` |
| 287 | +- Creates super_admin user with password (for initial setup in dev mode) |
| 288 | +- In production: create user via mix task, first OIDC login links to that user |
| 289 | + |
| 290 | +## Dependencies |
| 291 | + |
| 292 | +- `openid_connect` — OIDC discovery, token exchange, claims verification |
| 293 | +- `bcrypt_elixir` — Password hashing for dev mode only |
| 294 | + |
| 295 | +## Bootstrap Flow |
| 296 | + |
| 297 | +**First deployment:** |
| 298 | +1. Run migrations (V013 creates tables) |
| 299 | +2. Run `mix beacon.create_admin --email admin@example.com --password secret` (dev mode) or just `--email` (OIDC mode) |
| 300 | +3. Configure OIDC provider in config |
| 301 | +4. First login via OIDC matches the pre-provisioned email → user gains super_admin access |
| 302 | +5. Super admin creates more users and assigns roles via the Beacon admin UI |
0 commit comments