~comcloudway/builds.sr.ht

d05425f4aa4f6c23cc7188883f27a0d9a8c4c38f — Drew DeVault 1 year, 11 months ago 2eac168
API: implement user account deletion
A api/account/middleware.go => api/account/middleware.go +54 -0
@@ 0,0 1,54 @@
package account

import (
	"context"
	"database/sql"
	"log"
	"net/http"

	"git.sr.ht/~sircmpwn/core-go/database"
	work "git.sr.ht/~sircmpwn/dowork"
)

type contextKey struct {
	name string
}

var ctxKey = &contextKey{"account"}

func Middleware(queue *work.Queue) func(next http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := context.WithValue(r.Context(), ctxKey, queue)
			r = r.WithContext(ctx)
			next.ServeHTTP(w, r)
		})
	}
}

// Schedules a user account deletion.
func Delete(ctx context.Context, userID int, username string) {
	queue, ok := ctx.Value(ctxKey).(*work.Queue)
	if !ok {
		panic("No account worker for this context")
	}

	task := work.NewTask(func(ctx context.Context) error {
		log.Printf("Processing deletion of user account %d %s", userID, username)

		if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
			_, err := tx.ExecContext(ctx, `
				DELETE FROM "user" WHERE id = $1;
			`, userID)
			return err
		}); err != nil {
			return err
		}

		log.Printf("Deletion of user account %d %s complete", userID, username)
		return nil
	})

	queue.Enqueue(task)
	log.Printf("Enqueued deletion of user account %d %s", userID, username)
}

M api/go.mod => api/go.mod +1 -0
@@ 4,6 4,7 @@ go 1.16

require (
	git.sr.ht/~sircmpwn/core-go v0.0.0-20221025082458-3e69641ef307
	git.sr.ht/~sircmpwn/dowork v0.0.0-20210820133136-d3970e97def3
	github.com/99designs/gqlgen v0.17.20
	github.com/Masterminds/squirrel v1.4.0
	github.com/gocelery/gocelery v0.0.0-20201111034804-825d89059344

M api/graph/schema.graphqls => api/graph/schema.graphqls +11 -0
@@ 14,6 14,12 @@ access token, and are not available to clients using OAuth 2.0 access tokens.
"""
directive @private on FIELD_DEFINITION

"""
This is used to decorate fields which are for internal use, and are not
available to normal API users.
"""
directive @internal on FIELD_DEFINITION

enum AccessScope {
  PROFILE @scopehelp(details: "profile information")
  JOBS    @scopehelp(details: "build jobs")


@@ 488,4 494,9 @@ type Mutation {
  unexpected behavior with the third-party integration.
  """
  deleteUserWebhook(id: Int!): WebhookSubscription!

  """
  Deletes the authenticated user's account. Internal use only.
  """
  deleteUser: Int! @internal
}

M api/graph/schema.resolvers.go => api/graph/schema.resolvers.go +8 -0
@@ 14,6 14,7 @@ import (
	"strings"
	"time"

	"git.sr.ht/~sircmpwn/builds.sr.ht/api/account"
	"git.sr.ht/~sircmpwn/builds.sr.ht/api/graph/api"
	"git.sr.ht/~sircmpwn/builds.sr.ht/api/graph/model"
	"git.sr.ht/~sircmpwn/builds.sr.ht/api/loaders"


@@ 693,6 694,13 @@ func (r *mutationResolver) DeleteUserWebhook(ctx context.Context, id int) (model
	return &sub, nil
}

// DeleteUser is the resolver for the deleteUser field.
func (r *mutationResolver) DeleteUser(ctx context.Context) (int, error) {
	user := auth.ForContext(ctx)
	account.Delete(ctx, user.UserID, user.Username)
	return user.UserID, nil
}

// 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

M api/server.go => api/server.go +13 -2
@@ 7,8 7,10 @@ import (
	"git.sr.ht/~sircmpwn/core-go/config"
	"git.sr.ht/~sircmpwn/core-go/server"
	"git.sr.ht/~sircmpwn/core-go/webhooks"
	work "git.sr.ht/~sircmpwn/dowork"
	"github.com/99designs/gqlgen/graphql"

	"git.sr.ht/~sircmpwn/builds.sr.ht/api/account"
	"git.sr.ht/~sircmpwn/builds.sr.ht/api/graph"
	"git.sr.ht/~sircmpwn/builds.sr.ht/api/graph/api"
	"git.sr.ht/~sircmpwn/builds.sr.ht/api/graph/model"


@@ 20,6 22,7 @@ func main() {

	gqlConfig := api.Config{Resolvers: &graph.Resolver{}}
	gqlConfig.Directives.Private = server.Private
	gqlConfig.Directives.Internal = server.Internal
	gqlConfig.Directives.Access = func(ctx context.Context, obj interface{},
		next graphql.Resolver, scope model.AccessScope,
		kind model.AccessKind) (interface{}, error) {


@@ 36,12 39,20 @@ func main() {
		scopes[i] = s.String()
	}

	accountQueue := work.NewQueue("account")
	webhookQueue := webhooks.NewQueue(schema)

	server.NewServer("builds.sr.ht", appConfig).
		WithDefaultMiddleware().
		WithMiddleware(loaders.Middleware, webhooks.Middleware(webhookQueue)).
		WithMiddleware(
			loaders.Middleware,
			account.Middleware(accountQueue),
			webhooks.Middleware(webhookQueue),
		).
		WithSchema(schema, scopes).
		WithQueues(webhookQueue.Queue).
		WithQueues(
			accountQueue,
			webhookQueue.Queue,
		).
		Run()
}

M buildsrht-migrate => buildsrht-migrate +0 -1
@@ 3,4 3,3 @@ import buildsrht.alembic
import srht.alembic
from srht.database import alembic
alembic("builds.sr.ht", buildsrht.alembic)
alembic("builds.sr.ht", srht.alembic)

A buildsrht/alembic/versions/7e863c9389ef_configure_cascades_on_relationships.py => buildsrht/alembic/versions/7e863c9389ef_configure_cascades_on_relationships.py +44 -0
@@ 0,0 1,44 @@
"""Configure cascades on relationships

Revision ID: 7e863c9389ef
Revises: 6e5389a7ff68
Create Date: 2022-11-01 15:31:46.416272

"""

# revision identifiers, used by Alembic.
revision = '7e863c9389ef'
down_revision = '6e5389a7ff68'

from alembic import op
import sqlalchemy as sa

cascades = [
    ("secret", "user", "user_id", "CASCADE"),
    ("job_group", "user", "owner_id", "CASCADE"),
    ("job", "user", "owner_id", "CASCADE"),
    ("job", "job_group", "job_group_id", "SET NULL"),
    ("artifact", "job", "job_id", "CASCADE"),
    ("task", "job", "job_id", "CASCADE"),
    ("trigger", "job", "job_id", "CASCADE"),
    ("trigger", "job_group", "job_group_id", "CASCADE"),
    ("gql_user_wh_sub", "user", "user_id", "CASCADE"),
    ("oauthtoken", "user", "user_id", "CASCADE"),
]

def upgrade():
    for (table, relation, col, do) in cascades:
        op.execute(f"""
        ALTER TABLE {table} DROP CONSTRAINT IF EXISTS {table}_{col}_fkey;
        ALTER TABLE {table} ADD CONSTRAINT {table}_{col}_fkey
            FOREIGN KEY ({col})
            REFERENCES "{relation}"(id) ON DELETE {do};
        """)


def downgrade():
    for (table, relation, col, do) in tables:
        op.execute(f"""
        ALTER TABLE {table} DROP CONSTRAINT IF EXISTS {table}_{col}_fkey;
        ALTER TABLE {table} ADD CONSTRAINT {table}_{col}_fkey FOREIGN KEY ({col}) REFERENCES "{relation}"(id);
        """)

M schema.sql => schema.sql +10 -10
@@ 29,7 29,7 @@ CREATE TABLE "user" (

CREATE TABLE secret (
	id serial PRIMARY KEY,
	user_id integer NOT NULL REFERENCES "user"(id),
	user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
	created timestamp without time zone NOT NULL,
	updated timestamp without time zone NOT NULL,
	uuid uuid NOT NULL,


@@ 48,7 48,7 @@ CREATE TABLE job_group (
	id serial PRIMARY KEY,
	created timestamp without time zone NOT NULL,
	updated timestamp without time zone NOT NULL,
	owner_id integer NOT NULL REFERENCES "user"(id),
	owner_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
	note character varying(4096)
);



@@ 57,8 57,8 @@ CREATE TABLE job (
	created timestamp without time zone NOT NULL,
	updated timestamp without time zone NOT NULL,
	manifest character varying(16384) NOT NULL,
	owner_id integer NOT NULL REFERENCES "user"(id),
	job_group_id integer REFERENCES job_group(id),
	owner_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
	job_group_id integer REFERENCES job_group(id) ON DELETE SET NULL,
	note character varying(4096),
	tags character varying,
	runner character varying,


@@ 72,7 72,7 @@ CREATE INDEX ix_job_owner_id ON job USING btree (owner_id);
CREATE TABLE artifact (
	id serial PRIMARY KEY,
	created timestamp without time zone NOT NULL,
	job_id integer NOT NULL REFERENCES job(id),
	job_id integer NOT NULL REFERENCES job(id) ON DELETE CASCADE,
	name character varying NOT NULL,
	path character varying NOT NULL,
	url character varying NOT NULL,


@@ 85,7 85,7 @@ CREATE TABLE task (
	updated timestamp without time zone NOT NULL,
	name character varying(256) NOT NULL,
	status character varying NOT NULL,
	job_id integer NOT NULL REFERENCES job(id)
	job_id integer NOT NULL REFERENCES job(id) ON DELETE CASCADE
);

CREATE INDEX ix_task_job_id ON task USING btree (job_id);


@@ 97,8 97,8 @@ CREATE TABLE trigger (
	details character varying(4096) NOT NULL,
	condition character varying NOT NULL,
	trigger_type character varying NOT NULL,
	job_id integer REFERENCES job(id),
	job_group_id integer REFERENCES job_group(id)
	job_id integer REFERENCES job(id) ON DELETE CASCADE,
	job_group_id integer REFERENCES job_group(id) ON DELETE CASCADE
);

-- GraphQL webhooks


@@ 114,7 114,7 @@ CREATE TABLE gql_user_wh_sub (
	client_id uuid,
	expires timestamp without time zone,
	node_id character varying,
	user_id integer NOT NULL REFERENCES "user"(id),
	user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
	CONSTRAINT gql_user_wh_sub_auth_method_check
		CHECK ((auth_method = ANY (ARRAY['OAUTH2'::auth_method, 'INTERNAL'::public.auth_method]))),
	CONSTRAINT gql_user_wh_sub_check


@@ 150,7 150,7 @@ CREATE TABLE oauthtoken (
	created timestamp without time zone NOT NULL,
	updated timestamp without time zone NOT NULL,
	expires timestamp without time zone NOT NULL,
	user_id integer REFERENCES "user"(id),
	user_id integer REFERENCES "user"(id) ON DELETE CASCADE,
	token_hash character varying(128) NOT NULL,
	token_partial character varying(8) NOT NULL,
	scopes character varying(512) NOT NULL