From d05425f4aa4f6c23cc7188883f27a0d9a8c4c38f Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Tue, 1 Nov 2022 15:35:00 +0100 Subject: [PATCH] API: implement user account deletion --- api/account/middleware.go | 54 +++++++++++++++++++ api/go.mod | 1 + api/graph/schema.graphqls | 11 ++++ api/graph/schema.resolvers.go | 8 +++ api/server.go | 15 +++++- buildsrht-migrate | 1 - ...9ef_configure_cascades_on_relationships.py | 44 +++++++++++++++ schema.sql | 20 +++---- 8 files changed, 141 insertions(+), 13 deletions(-) create mode 100644 api/account/middleware.go create mode 100644 buildsrht/alembic/versions/7e863c9389ef_configure_cascades_on_relationships.py diff --git a/api/account/middleware.go b/api/account/middleware.go new file mode 100644 index 0000000..14c7ebe --- /dev/null +++ b/api/account/middleware.go @@ -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) +} diff --git a/api/go.mod b/api/go.mod index cb8af7a..865d904 100644 --- a/api/go.mod +++ b/api/go.mod @@ -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 diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index 6f429fe..d596000 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -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 } diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index 9054288..57c4089 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -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 diff --git a/api/server.go b/api/server.go index f7e9061..f93ebdb 100644 --- a/api/server.go +++ b/api/server.go @@ -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() } diff --git a/buildsrht-migrate b/buildsrht-migrate index e9cff8d..a43d7ed 100755 --- a/buildsrht-migrate +++ b/buildsrht-migrate @@ -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) diff --git a/buildsrht/alembic/versions/7e863c9389ef_configure_cascades_on_relationships.py b/buildsrht/alembic/versions/7e863c9389ef_configure_cascades_on_relationships.py new file mode 100644 index 0000000..8d7973c --- /dev/null +++ b/buildsrht/alembic/versions/7e863c9389ef_configure_cascades_on_relationships.py @@ -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); + """) diff --git a/schema.sql b/schema.sql index d6e8b1d..4ec5bd5 100644 --- a/schema.sql +++ b/schema.sql @@ -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 -- 2.38.5