Unverified Commit 3347cc05 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(panel): implement user management administration panel with sessions and...

feat(panel): implement user management administration panel with sessions and minecraft link support
parent 83e57b4c
Loading
Loading
Loading
Loading
+3 −3
Changes for cmd/felis/breakglass.go: 3 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -326,9 +326,9 @@ func provisionOwner(ctx context.Context, s ownerStore, username, email, password
}

// provisionOperator mints a NEW Operator staff account direct-to-Postgres. Like the
// Owner it is role=admin with must_change_password=true — Felis has no separate
// operator DB role, so an Operator is simply an additional staff admin (migration
// 0003). UNLIKE provisionOwner, which upserts the single Owner and resets it on a
// Owner it requires must_change_password=true but carries role='admin' (the single
// above-admin 'owner' role was added in migration 0011 and is exclusive to the first
// account — every subsequent staff is a plain admin). UNLIKE provisionOwner, which
// username conflict, this is insert-only: a username already taken returns
// api.ErrConflict rather than overwriting a live account, so adding an Operator can
// never silently clobber the Owner's or another Operator's credential. Only the
+509 −1
Changes for docs/openapi.yaml: 509 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -68,6 +68,8 @@ tags:
    description: Create / mutate server specs (external face, admin tier).
  - name: images
    description: Image build and whitelist administration (external face, admin tier).
  - name: users
    description: User administration (external face, admin tier only — exposed solely to owner).

components:
  securitySchemes:
@@ -326,6 +328,74 @@ components:
          format: date-time
          description: Null until an admin approves or rejects.

    UserView:
      type: object
      description: One row of the admin user list (internal/api/repo.go UserView).
      required: [id, username, role, disabled, email_verified, must_change_password, server_count, created_at, updated_at]
      properties:
        id: { type: string }
        username: { type: string }
        email: { type: string }
        role: { type: string, enum: [admin, user] }
        disabled: { type: boolean }
        email_verified: { type: boolean }
        server_count: { type: integer }
        must_change_password: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    UserDetail:
      type: object
      description: Full admin view of one user (internal/api/repo.go UserDetail).
      required: [id, username, role, disabled, email_verified, must_change_password, server_count, created_at, updated_at, linked_accounts]
      properties:
        id: { type: string }
        username: { type: string }
        email: { type: string }
        role: { type: string, enum: [admin, user] }
        disabled: { type: boolean }
        email_verified: { type: boolean }
        server_count: { type: integer }
        must_change_password: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at:
          type: [string, 'null']
          format: date-time
          description: Present only when soft-deleted.
        linked_accounts:
          type: array
          items:
            type: object
            required: [mc_uuid, auth_source, verified_at]
            properties:
              mc_uuid: { type: string, format: uuid }
              auth_source: { type: string }
              verified_at: { type: string, format: date-time }

    QuotaView:
      type: object
      description: A user's quotas row (internal/api/repo.go QuotaView). Null fields mean unlimited.
      required: [user_id]
      properties:
        user_id: { type: string }
        max_servers: { type: integer, nullable: true }
        max_cpu_milli: { type: integer, nullable: true }
        max_memory_mb: { type: integer, nullable: true }
        max_storage_gb: { type: integer, nullable: true }

    SessionView:
      type: object
      description: One live session of a user visible to an admin (internal/api/repo.go SessionView).
      required: [token_hash, created_at, expires_at]
      properties:
        token_hash: { type: string }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        revoked_at:
          type: [string, 'null']
          format: date-time

paths:
  # ----------------------------------------------------------------- health ---
  /healthz:
@@ -1785,13 +1855,451 @@ paths:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: Server is not stopped.
          description: Submission has already been reviewed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  # ------------------------------------------------------ users (admin tier) ----
  /api/v1/users:
    get:
      tags: [users]
      operationId: listUsers
      summary: List users (admin only).
      description: >-
        Returns a page of non-deleted users matching optional query filters, newest
        first. Every route under /users gates on the admin Zero-Trust path.
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: query, in: query, required: false, schema: { type: string }, description: Substring match on username or email }
        - { name: role, in: query, required: false, schema: { type: string, enum: [admin, user] } }
        - { name: disabled, in: query, required: false, schema: { type: string, enum: ["true", "false"] } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: offset, in: query, required: false, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: A page of users plus the total unfiltered count.
          content:
            application/json:
              schema:
                type: object
                required: [users, total]
                properties:
                  users:
                    type: array
                    items: { $ref: '#/components/schemas/UserView' }
                  total: { type: integer }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [users]
      operationId: createUser
      summary: Create a user (admin only).
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, role, password]
              properties:
                username: { type: string }
                email: { type: string, format: email }
                role: { type: string, enum: [admin, user] }
                password: { type: string, format: password }
                must_change_password: { type: boolean, default: true }
      responses:
        '201':
          description: User created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserView' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Username already taken.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/users/{id}:
    get:
      tags: [users]
      operationId: getUser
      summary: Get user detail (admin only).
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Full user detail including linked MC accounts.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserDetail' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [users]
      operationId: patchUser
      summary: Edit a user (admin only, cannot patch self).
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                username: { type: string }
                email: { type: string, format: email }
                role: { type: string, enum: [admin, user] }
      responses:
        '200':
          description: Updated user.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserView' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Username conflict.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      tags: [users]
      operationId: deleteUser
      summary: Soft-delete a user — releases servers, revokes sessions (admin only, cannot delete self).
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: User soft-deleted.
          content:
            application/json:
              schema:
                type: object
                required: [deleted]
                properties:
                  deleted: { type: boolean, const: true }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/users/{id}/disable:
    post:
      tags: [users]
      operationId: disableUser
      summary: Disable or re-enable a user (admin only, cannot disable self).
      description: >-
        Disabling a user additionally revokes every live session so the lockout is
        immediate. Re-enabling simply clears the flag.
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [disabled]
              properties:
                disabled: { type: boolean }
      responses:
        '200':
          description: Toggle applied.
          content:
            application/json:
              schema:
                type: object
                required: [id, disabled]
                properties:
                  id: { type: string }
                  disabled: { type: boolean }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/users/{id}/reset-password:
    post:
      tags: [users]
      operationId: resetPassword
      summary: >-
        Generate a high-entropy random password, deliver it to the user's email, and
        force a first-login change (admin only). No request body — the server owns
        entropy. The password is never returned to the admin; only the target email is echoed.
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Password reset. All existing sessions revoked. Password sent to the user's email.
          content:
            application/json:
              schema:
                type: object
                required: [ok, email]
                properties:
                  ok: { type: boolean, const: true }
                  email: { type: string, description: "The recipient email (empty if the user has none)." }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/users/{id}/quotas:
    get:
      tags: [users]
      operationId: getQuotas
      summary: Get a user's quotas (admin only).
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The user's current quotas (null=unlimited).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuotaView' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    put:
      tags: [users]
      operationId: setQuotas
      summary: Set a user's quotas (admin only).
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                max_servers: { type: integer, nullable: true }
                max_cpu_milli: { type: integer, nullable: true }
                max_memory_mb: { type: integer, nullable: true }
                max_storage_gb: { type: integer, nullable: true }
      responses:
        '200':
          description: Quotas updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuotaView' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v1/users/{id}/sessions:
    get:
      tags: [users]
      operationId: listUserSessions
      summary: List a user's live sessions (admin only).
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Live (unrevoked, unexpired) sessions, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [sessions]
                properties:
                  sessions:
                    type: array
                    items: { $ref: '#/components/schemas/SessionView' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    delete:
      tags: [users]
      operationId: revokeUserSessions
      summary: Revoke every live session of a user (admin only).
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: All sessions revoked.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean, const: true }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v1/users/{id}/sessions/{hash}:
    delete:
      tags: [users]
      operationId: revokeUserSession
      summary: Revoke a single session of a user (admin only).
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: hash, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Session revoked.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean, const: true }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v1/users/{id}/links:
    post:
      tags: [users]
      operationId: linkAccount
      summary: Force-link a Minecraft UUID to a user, bypassing the code-verification flow (admin only).
      description: >-
        The UUID must not already be bound to a different user (409). Same (user, uuid)
        pair is idempotent (200). auth_source defaults to "mojang".
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mc_uuid]
              properties:
                mc_uuid: { type: string, format: uuid }
                auth_source: { type: string, enum: [mojang, thirdparty], default: mojang }
      responses:
        '200':
          description: UUID linked (or was already linked to this user).
          content:
            application/json:
              schema:
                type: object
                required: [ok, mc_uuid, auth_source]
                properties:
                  ok: { type: boolean, const: true }
                  mc_uuid: { type: string, format: uuid }
                  auth_source: { type: string }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: UUID is already linked to a different user.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/users/{id}/links/{mc_uuid}:
    delete:
      tags: [users]
      operationId: unlinkAccount
      summary: Remove a single Minecraft UUID binding from a user (admin only).
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: UUID unlinked.
          content:
            application/json:
              schema:
                type: object
                required: [ok, mc_uuid]
                properties:
                  ok: { type: boolean, const: true }
                  mc_uuid: { type: string, format: uuid }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: No linked account for this UUID.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/account/link/start:
    post:
      tags: [account]
+36 −3
Changes for internal/api/api.go: 36 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -73,6 +73,11 @@ type API struct {
	// sender. The code is never returned to the client on either path.
	Mailer OTPMailer

	// ResetMailer delivers admin-generated password-reset passwords to the user's
	// verified email address. Same nil→server-side-log pattern as Mailer; the
	// password is never returned to the admin caller. Production wires a real sender.
	ResetMailer ResetMailer

	// Passkey verifies WebAuthn credential-creation ceremonies (spec §14 / Phase 6
	// passkey bind). It is optional: when nil the passkey register routes report 503
	// rather than panic, so the authenticated enrollment boundary is exercised before
@@ -219,6 +224,13 @@ type apiRoute struct {
	// handler). Internal-face routes never set it.
	Admin bool

	// Owner marks an external-face route that requires the platform-level owner
	// role (Principal.IsOwner()). It is orthogonal to Admin: an owner
	// intrinsically passes the admin ZT gate (IsAdmin() accepts both admin and
	// owner), so a route that sets Owner does not also need Admin. Mixing both
	// on one route is harmless but redundant — an owner passes both.
	Owner bool

	// AllowDuringPasswordChange opts a route OUT of the must_change_password
	// lockdown (spec §B). The lockdown is default-deny: every authenticated route is
	// fenced off for a staff principal that still owes a first-login password change
@@ -409,6 +421,24 @@ func (a *API) externalAPIRoutes() []apiRoute {
		// only — the runner/executors that consume the window are still INTEGRATION-ONLY.
		{Method: "GET", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleGetUpdateWindow},
		{Method: "PUT", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleSetUpdateWindow},

		// User admin (spec §7, owner-only). Every route gates on the admin Zero-Trust
		// path AND the owner role: listing, mutating, disabling, or deleting users is
		// an owner-tier operation (one level above admin).
		{Method: "GET", Pattern: "/api/v1/users", Owner: true, h: a.handleListUsers},
		{Method: "POST", Pattern: "/api/v1/users", Owner: true, h: a.handleCreateUser},
		{Method: "GET", Pattern: "/api/v1/users/{id}", Owner: true, h: a.handleGetUser},
		{Method: "PATCH", Pattern: "/api/v1/users/{id}", Owner: true, h: a.handlePatchUser},
		{Method: "DELETE", Pattern: "/api/v1/users/{id}", Owner: true, h: a.handleDeleteUser},
		{Method: "POST", Pattern: "/api/v1/users/{id}/disable", Owner: true, h: a.handleDisableUser},
		{Method: "POST", Pattern: "/api/v1/users/{id}/reset-password", Owner: true, h: a.handleResetPassword},
		{Method: "GET", Pattern: "/api/v1/users/{id}/quotas", Owner: true, h: a.handleGetQuotas},
		{Method: "PUT", Pattern: "/api/v1/users/{id}/quotas", Owner: true, h: a.handleSetQuotas},
		{Method: "GET", Pattern: "/api/v1/users/{id}/sessions", Owner: true, h: a.handleListUserSessions},
		{Method: "DELETE", Pattern: "/api/v1/users/{id}/sessions", Owner: true, h: a.handleRevokeUserSessions},
		{Method: "DELETE", Pattern: "/api/v1/users/{id}/sessions/{hash}", Owner: true, h: a.handleRevokeUserSession},
		{Method: "DELETE", Pattern: "/api/v1/users/{id}/links/{mc_uuid}", Owner: true, h: a.handleUnlinkAccount},
		{Method: "POST", Pattern: "/api/v1/users/{id}/links", Owner: true, h: a.handleLinkAccount},
	}
}

@@ -428,9 +458,9 @@ func (a *API) ExternalHandler() http.Handler {
// buildFace assembles one face from its route table. Public routes are mounted
// unauthenticated on the outer mux; the rest go on an inner mux behind guard
// (requireInternal / requireExternal), with Admin routes additionally wrapped in
// adminOnly. Because both faces are built from the same table the OpenAPI parity
// test reads, the served surface and the documented surface cannot drift apart
// without failing the build.
// adminOnly, and Owner routes in ownerOnly. Because both faces are built from the
// same table the OpenAPI parity test reads, the served surface and the documented
// surface cannot drift apart without failing the build.
func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler) http.Handler {
	mux := http.NewServeMux()
	auth := http.NewServeMux()
@@ -441,6 +471,9 @@ func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler
			continue
		}
		h := rt.h
		if rt.Owner {
			h = a.ownerOnly(rt.h)
		}
		if rt.Admin {
			h = a.adminOnly(rt.h)
		}
+231 −0

File changed.

Preview size limit exceeded, changes collapsed.

+10 −1
Changes for internal/api/auth.go: 10 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -36,8 +36,17 @@ type Principal struct {
// IsAdmin reports whether the principal may perform admin-tier operations.
// Both the role claim and the admin Access path are required: a role=admin
// session arriving on panel.* must not bypass the Zero-Trust boundary.
// An owner implicitly passes this check (the owner role is a superset of admin).
func (p *Principal) IsAdmin() bool {
	return p != nil && p.Role == "admin" && p.ViaAdminAccess
	return p != nil && (p.Role == "admin" || p.Role == "owner") && p.ViaAdminAccess
}

// IsOwner reports whether the principal holds the platform-level owner role
// — the single identity that may manage users, quotas, and sessions. Only the
// first staff account minted by break-glass carries this role; every subsequent
// Operator is a plain admin. Like IsAdmin, it requires the admin Access path.
func (p *Principal) IsOwner() bool {
	return p != nil && p.Role == "owner" && p.ViaAdminAccess
}

// InternalAuth authenticates the internal face (velocity / backend callbacks):
Loading