feat: store app logins agents can read

Username and password live in the local vault and the project vault, keyed by a stable app id rather than a host or port. Agents fetch one named secret or login when they need it. Lists stay metadata, and project reveals stay audit-logged.

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-10-29 09:31:02 +01:00
1 parent 61a45cc48f
commit 2a49de2db5
40 files changed
+1815 -43

No files matched your search

@@ -0,0 +1,19 @@
-- +goose Up
-- App logins: username + password bound to a stable app id, not a host:port.
-- The password is AES-256-GCM ciphertext. List returns metadata (including
-- the username and origin); reveal decrypts and is audit-logged.
CREATE TABLE core.project_logins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
app TEXT NOT NULL,
username TEXT NOT NULL,
ciphertext BYTEA NOT NULL,
origin TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, app)
);
-- +goose Down
DROP TABLE IF EXISTS core.project_logins;
@@ -0,0 +1,33 @@
-- name: ListProjectLogins :many
SELECT id, project_id, app, username, origin, notes, created_at, updated_at
FROM core.project_logins
WHERE project_id = $1
ORDER BY app ASC;
-- name: GetProjectLogin :one
SELECT * FROM core.project_logins
WHERE project_id = $1
AND app = $2;
-- name: UpsertProjectLogin :one
INSERT INTO core.project_logins (
project_id,
app,
username,
ciphertext,
origin,
notes
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (project_id, app) DO UPDATE SET
username = EXCLUDED.username,
ciphertext = EXCLUDED.ciphertext,
origin = EXCLUDED.origin,
notes = EXCLUDED.notes,
updated_at = NOW()
RETURNING *;
-- name: DeleteProjectLogin :one
DELETE FROM core.project_logins
WHERE project_id = $1
AND app = $2
RETURNING id;
@@ -361,6 +361,18 @@ type CoreProjectInvitation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreProjectLogin struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
App string `json:"app"`
Username string `json:"username"`
Ciphertext []byte `json:"ciphertext"`
Origin string `json:"origin"`
Notes string `json:"notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreProjectMember struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
@@ -0,0 +1,158 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: project_logins.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteProjectLogin = `-- name: DeleteProjectLogin :one
DELETE FROM core.project_logins
WHERE project_id = $1
AND app = $2
RETURNING id
`
type DeleteProjectLoginParams struct {
ProjectID uuid.UUID `json:"project_id"`
App string `json:"app"`
}
func (q *Queries) DeleteProjectLogin(ctx context.Context, arg DeleteProjectLoginParams) (uuid.UUID, error) {
row := q.db.QueryRow(ctx, deleteProjectLogin, arg.ProjectID, arg.App)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const getProjectLogin = `-- name: GetProjectLogin :one
SELECT id, project_id, app, username, ciphertext, origin, notes, created_at, updated_at FROM core.project_logins
WHERE project_id = $1
AND app = $2
`
type GetProjectLoginParams struct {
ProjectID uuid.UUID `json:"project_id"`
App string `json:"app"`
}
func (q *Queries) GetProjectLogin(ctx context.Context, arg GetProjectLoginParams) (CoreProjectLogin, error) {
row := q.db.QueryRow(ctx, getProjectLogin, arg.ProjectID, arg.App)
var i CoreProjectLogin
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.App,
&i.Username,
&i.Ciphertext,
&i.Origin,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listProjectLogins = `-- name: ListProjectLogins :many
SELECT id, project_id, app, username, origin, notes, created_at, updated_at
FROM core.project_logins
WHERE project_id = $1
ORDER BY app ASC
`
type ListProjectLoginsRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
App string `json:"app"`
Username string `json:"username"`
Origin string `json:"origin"`
Notes string `json:"notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListProjectLogins(ctx context.Context, projectID uuid.UUID) ([]ListProjectLoginsRow, error) {
rows, err := q.db.Query(ctx, listProjectLogins, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProjectLoginsRow
for rows.Next() {
var i ListProjectLoginsRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.App,
&i.Username,
&i.Origin,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertProjectLogin = `-- name: UpsertProjectLogin :one
INSERT INTO core.project_logins (
project_id,
app,
username,
ciphertext,
origin,
notes
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (project_id, app) DO UPDATE SET
username = EXCLUDED.username,
ciphertext = EXCLUDED.ciphertext,
origin = EXCLUDED.origin,
notes = EXCLUDED.notes,
updated_at = NOW()
RETURNING id, project_id, app, username, ciphertext, origin, notes, created_at, updated_at
`
type UpsertProjectLoginParams struct {
ProjectID uuid.UUID `json:"project_id"`
App string `json:"app"`
Username string `json:"username"`
Ciphertext []byte `json:"ciphertext"`
Origin string `json:"origin"`
Notes string `json:"notes"`
}
func (q *Queries) UpsertProjectLogin(ctx context.Context, arg UpsertProjectLoginParams) (CoreProjectLogin, error) {
row := q.db.QueryRow(ctx, upsertProjectLogin,
arg.ProjectID,
arg.App,
arg.Username,
arg.Ciphertext,
arg.Origin,
arg.Notes,
)
var i CoreProjectLogin
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.App,
&i.Username,
&i.Ciphertext,
&i.Origin,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -51,6 +51,7 @@ type Querier interface {
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
DeleteProjectLogin(ctx context.Context, arg DeleteProjectLoginParams) (uuid.UUID, error)
DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error)
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
DeleteSetting(ctx context.Context, key string) error
@@ -79,6 +80,7 @@ type Querier interface {
GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetProjectLogin(ctx context.Context, arg GetProjectLoginParams) (CoreProjectLogin, error)
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error)
@@ -120,6 +122,7 @@ type Querier interface {
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
ListProjectLogins(ctx context.Context, projectID uuid.UUID) ([]ListProjectLoginsRow, error)
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error)
ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error)
@@ -168,6 +171,7 @@ type Querier interface {
UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
UpsertProjectLogin(ctx context.Context, arg UpsertProjectLoginParams) (CoreProjectLogin, error)
UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error)
UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error)
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
+4
View File
@@ -137,6 +137,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret)
api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret)
api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret)
api.GET("/projects/:projectID/logins", h.listProjectLogins)
api.PUT("/projects/:projectID/logins/:app", h.setProjectLogin)
api.DELETE("/projects/:projectID/logins/:app", h.deleteProjectLogin)
api.POST("/projects/:projectID/logins/:app/reveal", h.revealProjectLogin)
api.GET("/projects/:projectID/alerts", h.listAlertRules)
api.POST("/projects/:projectID/alerts", h.createAlertRule)
+77
View File
@@ -0,0 +1,77 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
func (h *HTTPHandler) loginError(c *gin.Context, err error) {
msg := err.Error()
if strings.Contains(msg, "invalid app id") || strings.Contains(msg, "username is required") {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
return
}
h.secretError(c, err)
}
func (h *HTTPHandler) listProjectLogins(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListProjectLogins(c.Request.Context(), actor, projectID)
if err != nil {
h.loginError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) setProjectLogin(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.SetProjectLoginInput
if !h.bindAndValidate(c, &body) {
return
}
body.App = c.Param("app")
result, err := h.Platform.SetProjectLogin(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.loginError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) deleteProjectLogin(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
if err := h.Platform.DeleteProjectLogin(c.Request.Context(), actor, projectID, c.Param("app"), middleware.RequestIDFromContext(c)); err != nil {
h.loginError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) revealProjectLogin(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.RevealProjectLogin(c.Request.Context(), actor, projectID, c.Param("app"), middleware.RequestIDFromContext(c))
if err != nil {
h.loginError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
@@ -0,0 +1,174 @@
package services
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
// Project logins are website and app accounts: a stable app id, a username,
// and a password. The app id survives port changes (localhost:3000 is not an
// identity). Passwords are AES-256-GCM at rest; reveal is audit-logged.
var loginAppRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
type ProjectLoginSummary struct {
ID uuid.UUID `json:"id"`
App string `json:"app"`
Username string `json:"username"`
Origin string `json:"origin"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type SetProjectLoginInput struct {
App string `json:"app"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Origin *string `json:"origin"`
Notes *string `json:"notes"`
}
type RevealProjectLoginResponse struct {
App string `json:"app"`
Username string `json:"username"`
Password string `json:"password"`
Origin string `json:"origin"`
}
func validLoginApp(app string) bool {
return loginAppRe.MatchString(app)
}
func toProjectLoginSummary(row db.ListProjectLoginsRow) ProjectLoginSummary {
return ProjectLoginSummary{
ID: row.ID,
App: row.App,
Username: row.Username,
Origin: row.Origin,
Notes: row.Notes,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
func (s *PlatformService) ListProjectLogins(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ProjectLoginSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListProjectLogins(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]ProjectLoginSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toProjectLoginSummary(row))
}
return out, nil
}
func (s *PlatformService) SetProjectLogin(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input SetProjectLoginInput, requestID string) (ProjectLoginSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return ProjectLoginSummary{}, err
}
app := strings.TrimSpace(input.App)
if !validLoginApp(app) {
return ProjectLoginSummary{}, fmt.Errorf("invalid app id — use [A-Za-z0-9][A-Za-z0-9._-]{0,63}, not a URL or port")
}
username := strings.TrimSpace(input.Username)
if username == "" {
return ProjectLoginSummary{}, fmt.Errorf("username is required")
}
if s.enc == nil {
return ProjectLoginSummary{}, fmt.Errorf("encryption is not configured on this deployment")
}
ciphertext, err := s.enc.Encrypt([]byte(input.Password))
if err != nil {
return ProjectLoginSummary{}, err
}
var origin, notes string
if existing, err := s.repo.Queries().GetProjectLogin(ctx, db.GetProjectLoginParams{ProjectID: projectID, App: app}); err == nil {
origin, notes = existing.Origin, existing.Notes
} else if err != pgx.ErrNoRows {
return ProjectLoginSummary{}, err
}
if input.Origin != nil {
origin = strings.TrimSpace(*input.Origin)
}
if input.Notes != nil {
notes = strings.TrimSpace(*input.Notes)
}
row, err := s.repo.Queries().UpsertProjectLogin(ctx, db.UpsertProjectLoginParams{
ProjectID: projectID,
App: app,
Username: username,
Ciphertext: ciphertext,
Origin: origin,
Notes: notes,
})
if err != nil {
return ProjectLoginSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "login.set", "project_login", row.ID.String(), map[string]any{
"app": row.App,
}))
}
return ProjectLoginSummary{
ID: row.ID,
App: row.App,
Username: row.Username,
Origin: row.Origin,
Notes: row.Notes,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}, nil
}
func (s *PlatformService) DeleteProjectLogin(ctx context.Context, actor *models.Actor, projectID uuid.UUID, app string, requestID string) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return err
}
row, err := s.repo.Queries().DeleteProjectLogin(ctx, db.DeleteProjectLoginParams{ProjectID: projectID, App: app})
if err != nil {
return err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "login.deleted", "project_login", row.String(), map[string]any{
"app": app,
}))
}
return nil
}
func (s *PlatformService) RevealProjectLogin(ctx context.Context, actor *models.Actor, projectID uuid.UUID, app string, requestID string) (RevealProjectLoginResponse, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return RevealProjectLoginResponse{}, err
}
if s.enc == nil {
return RevealProjectLoginResponse{}, fmt.Errorf("encryption is not configured on this deployment")
}
row, err := s.repo.Queries().GetProjectLogin(ctx, db.GetProjectLoginParams{ProjectID: projectID, App: app})
if err != nil {
return RevealProjectLoginResponse{}, err
}
plain, err := s.enc.Decrypt(row.Ciphertext)
if err != nil {
return RevealProjectLoginResponse{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "login.revealed", "project_login", row.ID.String(), map[string]any{
"app": row.App,
}))
}
return RevealProjectLoginResponse{App: row.App, Username: row.Username, Password: string(plain), Origin: row.Origin}, nil
}
@@ -0,0 +1,27 @@
package services
import (
"strings"
"testing"
)
func TestValidLoginApp(t *testing.T) {
ok := []string{"primora", "MyApp", "app.local", "stripe-dashboard", "a"}
bad := []string{"", "has space", "http://localhost:3000", "/etc/passwd", "..", "-leading", "a/b"}
for _, app := range ok {
if !validLoginApp(app) {
t.Errorf("validLoginApp(%q) = false", app)
}
}
for _, app := range bad {
if validLoginApp(app) {
t.Errorf("validLoginApp(%q) = true", app)
}
}
if !validLoginApp(strings.Repeat("a", 64)) {
t.Error("64-char app id rejected")
}
if validLoginApp(strings.Repeat("a", 65)) {
t.Error("65-char app id accepted")
}
}
+149
View File
@@ -2041,6 +2041,91 @@ paths:
$ref: "#/components/responses/ErrorResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/logins:
get:
tags: [Secrets]
operationId: listProjectLogins
summary: Lists app logins — username and origin, never the password.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
responses:
"200":
description: Project logins (password omitted)
content:
application/json:
schema:
$ref: "#/components/schemas/ProjectLoginListResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/logins/{app}:
put:
tags: [Secrets]
operationId: setProjectLogin
summary: Creates or replaces an app login. The password is AES-256-GCM at rest. Omitted origin/notes keep existing values. The app id is stable — it is not a host or port.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/LoginApp"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SetProjectLoginRequest"
responses:
"200":
description: Login stored
content:
application/json:
schema:
$ref: "#/components/schemas/ProjectLogin"
"400":
$ref: "#/components/responses/ErrorResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
delete:
tags: [Secrets]
operationId: deleteProjectLogin
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/LoginApp"
responses:
"204":
description: Login deleted
"401":
$ref: "#/components/responses/ErrorResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/logins/{app}/reveal:
post:
tags: [Secrets]
operationId: revealProjectLogin
summary: Returns the username, password, and origin for one app. Every call is audit-logged. Read one login when you need it. Do not copy it into chat or a .env file.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/LoginApp"
responses:
"200":
description: Decrypted login
content:
application/json:
schema:
$ref: "#/components/schemas/RevealProjectLoginResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/alerts:
get:
tags: [Alerts]
@@ -2475,6 +2560,14 @@ components:
schema:
type: string
pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
LoginApp:
name: app
in: path
required: true
description: Stable app id. Not a URL and not a port.
schema:
type: string
pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"
BucketID:
name: bucketID
in: path
@@ -4408,6 +4501,62 @@ components:
value:
type: string
required: [name, value]
ProjectLogin:
type: object
properties:
id:
type: string
format: uuid
app:
type: string
description: Stable app id. The current origin is separate and may change.
username:
type: string
origin:
type: string
description: Current URL of the app, if known. Not the identity.
notes:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required: [id, app, username, origin, notes, created_at, updated_at]
ProjectLoginListResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/ProjectLogin"
required: [items]
SetProjectLoginRequest:
type: object
properties:
username:
type: string
password:
type: string
description: Plaintext — stored AES-256-GCM encrypted, never returned by list.
origin:
type: string
notes:
type: string
required: [username, password]
RevealProjectLoginResponse:
type: object
properties:
app:
type: string
username:
type: string
password:
type: string
origin:
type: string
required: [app, username, password, origin]
AlertRuleConfig:
type: object
properties: