~comcloudway/builds.sr.ht

6657f4a82e570853c68d501e9ed67fb2ac5c8e75 — Drew DeVault 1 year, 1 month ago b456338
all: implement secret sharing
M api/graph/model/secrets.go => api/graph/model/secrets.go +19 -7
@@ 32,6 32,8 @@ type RawSecret struct {
	Path       *string
	Mode       *int

	FromUserID int

	alias  string
	fields *database.ModelFields
}


@@ 55,6 57,8 @@ type PGPKey struct {
	UUID       string    `json:"uuid"`
	Name       *string   `json:"name"`
	PrivateKey []byte    `json:"privateKey"`

	FromUserID int
}

func (PGPKey) IsSecret() {}


@@ 65,6 69,8 @@ type SSHKey struct {
	UUID       string    `json:"uuid"`
	Name       *string   `json:"name"`
	PrivateKey []byte    `json:"privateKey"`

	FromUserID int
}

func (SSHKey) IsSecret() {}


@@ 77,6 83,8 @@ type SecretFile struct {
	Path    string    `json:"path"`
	Mode    int       `json:"mode"`
	Data    []byte    `json:"data"`

	FromUserID int
}

func (SecretFile) IsSecret() {}


@@ 90,6 98,7 @@ func (s *RawSecret) ToSecret() Secret {
			UUID:       s.UUID,
			Name:       s.Name,
			PrivateKey: s.Secret,
			FromUserID: s.FromUserID,
		}
	case SECRET_SSHKEY:
		return &SSHKey{


@@ 98,16 107,18 @@ func (s *RawSecret) ToSecret() Secret {
			UUID:       s.UUID,
			Name:       s.Name,
			PrivateKey: s.Secret,
			FromUserID: s.FromUserID,
		}
	case SECRET_FILE:
		return &SecretFile{
			ID:      s.ID,
			Created: s.Created,
			UUID:    s.UUID,
			Name:    s.Name,
			Path:    *s.Path,
			Mode:    *s.Mode,
			Data:    s.Secret,
			ID:         s.ID,
			Created:    s.Created,
			UUID:       s.UUID,
			Name:       s.Name,
			Path:       *s.Path,
			Mode:       *s.Mode,
			Data:       s.Secret,
			FromUserID: s.FromUserID,
		}
	default:
		panic("Database invariant broken: unknown secret type")


@@ 126,6 137,7 @@ func (s *RawSecret) Fields() *database.ModelFields {

			// Always fetch:
			{"id", "", &s.ID},
			{"from_user_id", "", &s.FromUserID},
			{"secret_type", "", &s.SecretType},
			{"secret", "", &s.Secret},
			{"path", "", &s.Path},

M api/graph/schema.graphqls => api/graph/schema.graphqls +8 -0
@@ 232,6 232,8 @@ interface Secret {
  created: Time!
  uuid: String!
  name: String
  "Set when this secret was copied from another user account"
  from_user: Entity
}

"""


@@ 251,6 253,7 @@ type SSHKey implements Secret {
  created: Time!
  uuid: String!
  name: String
  from_user: Entity
  privateKey: Binary! @worker
}



@@ 259,6 262,7 @@ type PGPKey implements Secret {
  created: Time!
  uuid: String!
  name: String
  from_user: Entity
  privateKey: Binary! @worker
}



@@ 267,6 271,7 @@ type SecretFile implements Secret {
  created: Time!
  uuid: String!
  name: String
  from_user: Entity
  path: String!
  mode: Int!
  data: Binary! @worker


@@ 466,6 471,9 @@ type Mutation {
  "Starts a pending job group."
  startGroup(groupId: Int!): JobGroup @access(scope: JOBS, kind: RW)

  "Copies a secret to the target user account."
  shareSecret(uuid: String!, user: String!): Secret! @access(scope: SECRETS, kind: RW)

  ###
  ### The following resolvers are for internal worker use


M api/graph/schema.resolvers.go => api/graph/schema.resolvers.go +98 -0
@@ 25,6 25,7 @@ import (
	"git.sr.ht/~sircmpwn/core-go/database"
	coremodel "git.sr.ht/~sircmpwn/core-go/model"
	"git.sr.ht/~sircmpwn/core-go/server"
	"git.sr.ht/~sircmpwn/core-go/valid"
	corewebhooks "git.sr.ht/~sircmpwn/core-go/webhooks"
	sq "github.com/Masterminds/squirrel"
	"github.com/google/uuid"


@@ 600,6 601,88 @@ func (r *mutationResolver) StartGroup(ctx context.Context, groupID int) (*model.
	return &group, nil
}

// ShareSecret is the resolver for the shareSecret field.
func (r *mutationResolver) ShareSecret(ctx context.Context, uuid string, user string) (model.Secret, error) {
	var sec model.Secret

	valid := valid.New(ctx)
	target, err := loaders.ForContext(ctx).UsersByName.Load(user)
	if err != nil || target == nil {
		valid.Error("No such user").WithField("user")
	}
	if !valid.Ok() {
		return nil, nil
	}

	if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
		row := tx.QueryRowContext(ctx, `
			INSERT INTO secret (
				user_id,
				created,
				updated,
				uuid,
				name,
				from_user_id,
				secret_type,
				secret,
				path,
				mode
			)
			SELECT
				$3,
				created,
				updated,
				uuid,
				name,
				$1,
				secret_type,
				secret,
				path,
				mode
			FROM secret
			WHERE
				user_id = $1 AND uuid = $2
			RETURNING
				id,
				created,
				uuid,
				name,
				from_user_id,
				secret_type,
				secret bytea,
				path,
				mode;
		`, auth.ForContext(ctx).UserID, uuid, target.ID)

		var raw model.RawSecret
		if err := row.Scan(
			&raw.ID,
			&raw.Created,
			&raw.UUID,
			&raw.Name,
			&raw.FromUserID,
			&raw.SecretType,
			&raw.Secret,
			&raw.Path,
			&raw.Mode,
		); err != nil {
			return err
		}
		sec = raw.ToSecret()
		return nil
	}); err != nil {
		if err == sql.ErrNoRows {
			valid.Error("No such secret").WithField("uuid")
		}
		if !valid.Ok() {
			return nil, nil
		}
		return nil, err
	}

	return sec, nil
}

// Claim is the resolver for the claim field.
func (r *mutationResolver) Claim(ctx context.Context, jobID int) (*model.Job, error) {
	panic(fmt.Errorf("not implemented"))


@@ 732,6 815,11 @@ func (r *mutationResolver) DeleteUser(ctx context.Context) (int, error) {
	return user.UserID, nil
}

// FromUser is the resolver for the from_user field.
func (r *pGPKeyResolver) FromUser(ctx context.Context, obj *model.PGPKey) (model.Entity, error) {
	return loaders.ForContext(ctx).UsersByID.Load(obj.FromUserID)
}

// PrivateKey is the resolver for the privateKey field.
func (r *pGPKeyResolver) PrivateKey(ctx context.Context, obj *model.PGPKey) (string, error) {
	// TODO: This is simple to implement, but I'm not going to rig it up until


@@ 915,6 1003,11 @@ func (r *queryResolver) Webhook(ctx context.Context) (model.WebhookPayload, erro
	return payload, nil
}

// FromUser is the resolver for the from_user field.
func (r *sSHKeyResolver) FromUser(ctx context.Context, obj *model.SSHKey) (model.Entity, error) {
	return loaders.ForContext(ctx).UsersByID.Load(obj.FromUserID)
}

// PrivateKey is the resolver for the privateKey field.
func (r *sSHKeyResolver) PrivateKey(ctx context.Context, obj *model.SSHKey) (string, error) {
	// TODO: This is simple to implement, but I'm not going to rig it up until


@@ 922,6 1015,11 @@ func (r *sSHKeyResolver) PrivateKey(ctx context.Context, obj *model.SSHKey) (str
	panic(fmt.Errorf("not implemented"))
}

// FromUser is the resolver for the from_user field.
func (r *secretFileResolver) FromUser(ctx context.Context, obj *model.SecretFile) (model.Entity, error) {
	return loaders.ForContext(ctx).UsersByID.Load(obj.FromUserID)
}

// Data is the resolver for the data field.
func (r *secretFileResolver) Data(ctx context.Context, obj *model.SecretFile) (string, error) {
	// TODO: This is simple to implement, but I'm not going to rig it up until

A buildsrht/alembic/versions/7a5df483f0ee_add_secret_sharing.py => buildsrht/alembic/versions/7a5df483f0ee_add_secret_sharing.py +34 -0
@@ 0,0 1,34 @@
"""Add secret sharing

Revision ID: 7a5df483f0ee
Revises: ae3544d6450a
Create Date: 2023-08-28 09:49:34.659942

"""

# revision identifiers, used by Alembic.
revision = '7a5df483f0ee'
down_revision = 'ae3544d6450a'

from alembic import op
import sqlalchemy as sa


def upgrade():
    op.execute("""
    ALTER TABLE secret
    ADD COLUMN from_user_id integer REFERENCES "user"(id) ON DELETE SET NULL;

    ALTER TABLE secret
    ADD CONSTRAINT secret_user_id_uuid_unique UNIQUE (user_id, uuid);
    """)


def downgrade():
    op.execute("""
    ALTER TABLE secret
    DROP COLUMN from_user_id;

    ALTER TABLE secret
    DROP CONSTRAINT secret_user_id_uuid_unique;
    """)

M buildsrht/blueprints/secrets.py => buildsrht/blueprints/secrets.py +46 -7
@@ 4,6 4,7 @@ from cryptography.hazmat.primitives import serialization
from flask import Blueprint, render_template, request, redirect, abort
from srht.database import db
from srht.flask import session
from srht.graphql import exec_gql
from srht.oauth import current_user, loginrequired
from srht.validation import Validation



@@ 93,12 94,11 @@ def secrets_POST():
@secrets.route("/secret/delete/<uuid>")
@loginrequired
def secret_delete_GET(uuid):
    secret = Secret.query.filter(Secret.uuid == uuid).first()
    secret = (Secret.query
        .filter(Secret.uuid == uuid)
        .filter(Secret.user_id == current_user.id).first())
    if not secret:
        abort(404)
    if secret.user_id != current_user.id:
        abort(401)

    return render_template("secret_delete.html", secret=secret)




@@ 111,11 111,11 @@ def secret_delete_POST():
    if not uuid:
        abort(404)

    secret = Secret.query.filter(Secret.uuid == uuid).first()
    secret = (Secret.query
        .filter(Secret.uuid == uuid)
        .filter(Secret.user_id == current_user.id).first())
    if not secret:
        abort(404)
    if secret.user_id != current_user.id:
        abort(401)

    name = secret.name
    db.session.delete(secret)


@@ 124,3 124,42 @@ def secret_delete_POST():
    session["message"] = "Successfully removed secret {}{}.".format(uuid,
            " ({})".format(name) if name else "")
    return redirect("/secrets")

@secrets.route("/secret/share/<uuid>")
@loginrequired
def secret_share_GET(uuid):
    secret = (Secret.query
        .filter(Secret.uuid == uuid)
        .filter(Secret.user_id == current_user.id).first())
    if not secret:
        abort(404)
    return render_template("secret_share.html", secret=secret)

@secrets.route("/secret/share/<uuid>", methods=["POST"])
@loginrequired
def secret_share_POST(uuid):
    secret = (Secret.query
        .filter(Secret.uuid == uuid)
        .filter(Secret.user_id == current_user.id).first())
    if not secret:
        abort(404)

    valid = Validation(request)
    username = valid.require("username", friendly_name="Username")
    if not valid.ok:
        return render_template("secret_share.html",
                   secret=secret, valid=valid)

    resp = exec_gql("builds.sr.ht", """
    mutation ShareSecret($uuid: String!, $target: String!) {
        shareSecret(uuid: $uuid, user: $target) { id }
    }
    """, valid=valid, uuid=uuid, target=username)
    if not valid.ok:
        return render_template("secret_share.html",
                   secret=secret, valid=valid)

    session["message"] = "{} successfully shared with {}.".format(
            secret.name if secret.name else secret.uuid,
            username)
    return redirect("/secrets")

M buildsrht/templates/secret_delete.html => buildsrht/templates/secret_delete.html +2 -2
@@ 24,10 24,10 @@
      {{csrf_token()}}
      <input type="hidden" name="uuid" value="{{ secret.uuid }}"></input>
      <button type="submit" class="btn btn-danger">
        Permanently delete secret
        Permanently delete secret {{icon('caret-right')}}
      </button>
      <a href="/secrets" class="btn btn-default">
        Cancel, keep secret
        Cancel operation and keep this secret {{icon('caret-right')}}
      </a>
    </form>
  </div>

A buildsrht/templates/secret_share.html => buildsrht/templates/secret_share.html +59 -0
@@ 0,0 1,59 @@
{% extends "layout.html" %}
{% block content %}
<section class="row">
  <div class="col-md-8 offset-md-2">
    <h2>
      Share "{{ secret.name or secret.uuid }}" with another user
    </h2>
    <dl>
      {% if secret.name %}
      <dt>UUID</dt>
      <dd>{{ secret.uuid }}</dd>
      {% endif %}
      <dt>Type</dt>
      <dd>{{ secret.secret_type.pretty_name }}</dd>
      <dt>Created</dt>
      <dd>{{ secret.created | date }}</dd>
      <dt>Last used</dt>
      <dd>{{ secret.updated | date }}</dd>
      {% if secret.secret_type.value == "plaintext_file" %}
      <dt>File</dt>
      <dd>{{ secret.path }} (<i>{{ '%03o' % secret.mode }}</i> mode)</dd>
      {% endif %}
    <dl>
    <div class="alert alert-danger">
      {{icon('exclamation-triangle')}}
      <strong>
        This action cannot be undone!
      </strong>
      The secret will be copied to the target user's account and cannot be
      unshared. The only way to remove access to a shared secret key is to
      generate a new secret and update your build manifests.
    </div>
    <form method="POST">
      {{csrf_token()}}
      <div class="form-group">
        <label for="username">Username</label>
        <input
           type="text"
           name="username"
           id="username"
           value="{{ username }}"
           class="form-control {{valid.cls("username")}}"
           required
           autocomplete="username" />
        {{valid.summary("username")}}
      </div>

      {{valid.summary()}}

      <button type="submit" class="btn btn-danger">
        Share "{{ secret.name or secret.uuid }}" with this user {{icon('caret-right')}}
      </button>
      <a href="/secrets" class="btn btn-default">
        Cancel {{icon('caret-right')}}
      </a>
    </form>
  </div>
</section>
{% endblock %}

M buildsrht/templates/secrets.html => buildsrht/templates/secrets.html +13 -4
@@ 154,13 154,22 @@
          <code style="padding: 0;">{{ secret.uuid }}</code>
          <small class="pull-right">{{ secret.created | date }}</small>
        </h4>
        {% if secret.from_user %}
        <small>Shared by {{secret.from_user.canonical_name}}</small>
        {% endif %}
        {% if secret.name %}
        <div>{{ secret.name }}</div>
        {% endif %}
        <a
          href="/secret/delete/{{ secret.uuid }}"
          class="btn btn-danger pull-right"
        >Delete</a>
        <div class="pull-right">
          <a
            href="/secret/share/{{ secret.uuid }}"
            class="btn btn-default"
          >Share {{icon('caret-right')}}</a>
          <a
            href="/secret/delete/{{ secret.uuid }}"
            class="btn btn-danger"
          >Delete {{icon('caret-right')}}</a>
        </div>
        <div>
          {{secret.secret_type.pretty_name}}{%
            if secret.secret_type.value == "plaintext_file"

M buildsrht/types/secret.py => buildsrht/types/secret.py +3 -1
@@ 23,7 23,9 @@ class Secret(Base):
    __tablename__ = "secret"
    id = sa.Column(sa.Integer, primary_key=True)
    user_id = sa.Column(sa.Integer, sa.ForeignKey("user.id"), nullable=False)
    user = sa.orm.relationship("User", backref="secrets")
    user = sa.orm.relationship("User", backref="secrets", foreign_keys=[user_id])
    from_user_id = sa.Column(sa.Integer, sa.ForeignKey("user.id"))
    from_user = sa.orm.relationship("User", foreign_keys=[from_user_id])
    created = sa.Column(sa.DateTime, nullable=False)
    updated = sa.Column(sa.DateTime, nullable=False)
    uuid = sa.Column(sau.UUIDType, nullable=False)

M schema.sql => schema.sql +3 -1
@@ 40,12 40,14 @@ CREATE TABLE secret (
	updated timestamp without time zone NOT NULL,
	uuid uuid NOT NULL,
	name character varying(512),
	from_user_id integer REFERENCES "user"(id) ON DELETE SET NULL,
	-- Key secrets:
	secret_type character varying NOT NULL,
	secret bytea NOT NULL,
	-- File secrets:
	path character varying(512),
	mode integer
	mode integer,
	CONSTRAINT secret_user_id_uuid_unique UNIQUE (user_id, uuid)
);

CREATE INDEX ix_user_username ON "user" USING btree (username);

M worker/database.go => worker/database.go +5 -4
@@ 89,16 89,17 @@ func GetSecret(db *sql.DB, sec string, ownerId int) (*Secret, error) {
	if err != nil {
		return GetSecretByName(db, sec, ownerId)
	}
	return GetSecretById(db, sec)
	return GetSecretById(db, sec, ownerId)
}

func GetSecretById(db *sql.DB, uuid string) (*Secret, error) {
func GetSecretById(db *sql.DB, uuid string, ownerId int) (*Secret, error) {
	row := db.QueryRow(`
		SELECT
			"id", "user_id", "created", "updated", "uuid",
			"name", "secret_type", "secret", "path", "mode"
		FROM "secret" WHERE "uuid" = $1;
	`, uuid)
		FROM secret
    WHERE uuid = $1 AND user_id = $2;
	`, uuid, ownerId)
	var secret Secret
	if err := row.Scan(
		&secret.Id, &secret.UserId, &secret.Created, &secret.Updated,