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,