mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-29 16:14:53 +00:00
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:
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user