From 6657f4a82e570853c68d501e9ed67fb2ac5c8e75 Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Mon, 28 Aug 2023 11:01:14 +0200 Subject: [PATCH] all: implement secret sharing --- api/graph/model/secrets.go | 26 +++-- api/graph/schema.graphqls | 8 ++ api/graph/schema.resolvers.go | 98 +++++++++++++++++++ .../7a5df483f0ee_add_secret_sharing.py | 34 +++++++ buildsrht/blueprints/secrets.py | 53 ++++++++-- buildsrht/templates/secret_delete.html | 4 +- buildsrht/templates/secret_share.html | 59 +++++++++++ buildsrht/templates/secrets.html | 17 +++- buildsrht/types/secret.py | 4 +- schema.sql | 4 +- worker/database.go | 9 +- 11 files changed, 290 insertions(+), 26 deletions(-) create mode 100644 buildsrht/alembic/versions/7a5df483f0ee_add_secret_sharing.py create mode 100644 buildsrht/templates/secret_share.html diff --git a/api/graph/model/secrets.go b/api/graph/model/secrets.go index b09e682..e2c1aa6 100644 --- a/api/graph/model/secrets.go +++ b/api/graph/model/secrets.go @@ -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}, diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index b8c2641..ef9e6e2 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -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 diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index 1e4f410..029c81c 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -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 diff --git a/buildsrht/alembic/versions/7a5df483f0ee_add_secret_sharing.py b/buildsrht/alembic/versions/7a5df483f0ee_add_secret_sharing.py new file mode 100644 index 0000000..45c8878 --- /dev/null +++ b/buildsrht/alembic/versions/7a5df483f0ee_add_secret_sharing.py @@ -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; + """) diff --git a/buildsrht/blueprints/secrets.py b/buildsrht/blueprints/secrets.py index 3162e99..3c4dd79 100644 --- a/buildsrht/blueprints/secrets.py +++ b/buildsrht/blueprints/secrets.py @@ -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/") @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/") +@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/", 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") diff --git a/buildsrht/templates/secret_delete.html b/buildsrht/templates/secret_delete.html index 9fcc582..94587b0 100644 --- a/buildsrht/templates/secret_delete.html +++ b/buildsrht/templates/secret_delete.html @@ -24,10 +24,10 @@ {{csrf_token()}} - Cancel, keep secret + Cancel operation and keep this secret {{icon('caret-right')}} diff --git a/buildsrht/templates/secret_share.html b/buildsrht/templates/secret_share.html new file mode 100644 index 0000000..5f45d93 --- /dev/null +++ b/buildsrht/templates/secret_share.html @@ -0,0 +1,59 @@ +{% extends "layout.html" %} +{% block content %} +
+
+

+ Share "{{ secret.name or secret.uuid }}" with another user +

+
+ {% if secret.name %} +
UUID
+
{{ secret.uuid }}
+ {% endif %} +
Type
+
{{ secret.secret_type.pretty_name }}
+
Created
+
{{ secret.created | date }}
+
Last used
+
{{ secret.updated | date }}
+ {% if secret.secret_type.value == "plaintext_file" %} +
File
+
{{ secret.path }} ({{ '%03o' % secret.mode }} mode)
+ {% endif %} +
+
+ {{icon('exclamation-triangle')}} + + This action cannot be undone! + + 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. +
+
+ {{csrf_token()}} +
+ + + {{valid.summary("username")}} +
+ + {{valid.summary()}} + + + + Cancel {{icon('caret-right')}} + +
+
+
+{% endblock %} diff --git a/buildsrht/templates/secrets.html b/buildsrht/templates/secrets.html index f119900..6fa663a 100644 --- a/buildsrht/templates/secrets.html +++ b/buildsrht/templates/secrets.html @@ -154,13 +154,22 @@ {{ secret.uuid }} {{ secret.created | date }} + {% if secret.from_user %} + Shared by {{secret.from_user.canonical_name}} + {% endif %} {% if secret.name %}
{{ secret.name }}
{% endif %} - Delete +
{{secret.secret_type.pretty_name}}{% if secret.secret_type.value == "plaintext_file" diff --git a/buildsrht/types/secret.py b/buildsrht/types/secret.py index 506ba6c..794c8bb 100644 --- a/buildsrht/types/secret.py +++ b/buildsrht/types/secret.py @@ -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) diff --git a/schema.sql b/schema.sql index 073467c..cb53ea7 100644 --- a/schema.sql +++ b/schema.sql @@ -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); diff --git a/worker/database.go b/worker/database.go index 78d2522..0fdecaa 100644 --- a/worker/database.go +++ b/worker/database.go @@ -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, -- 2.38.5