API: implement user account deletion

This commit is contained in:
Drew DeVault 2022-11-01 15:57:29 +01:00
parent a26fc1fbfd
commit d1c0e43b66
6 changed files with 156 additions and 17 deletions

68
api/account/middleware.go Normal file
View File

@ -0,0 +1,68 @@
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, `
UPDATE participant
SET
participant_type = 'email',
user_id = null,
email = u.email,
email_name = '~' || u.username
FROM "user" u
WHERE u.id = $1 AND participant.user_id = $1
`, userID)
if err != nil {
return err
}
_, 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)
}

View File

@ -991,4 +991,9 @@ type Mutation {
"Deletes a ticket webhook."
deleteTicketWebhook(id: Int!): WebhookSubscription!
"""
Deletes the authenticated user's account. Internal use only.
"""
deleteUser: Int! @internal
}

View File

@ -20,6 +20,7 @@ import (
"git.sr.ht/~sircmpwn/core-go/server"
"git.sr.ht/~sircmpwn/core-go/valid"
corewebhooks "git.sr.ht/~sircmpwn/core-go/webhooks"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/account"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/api"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/model"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/imports"
@ -2404,6 +2405,13 @@ func (r *mutationResolver) DeleteTicketWebhook(ctx context.Context, id int) (mod
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
}
// Version is the resolver for the version field.
func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
return &model.Version{

View File

@ -9,6 +9,7 @@ import (
work "git.sr.ht/~sircmpwn/dowork"
"github.com/99designs/gqlgen/graphql"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/account"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/api"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/model"
@ -34,6 +35,7 @@ func main() {
scopes[i] = s.String()
}
accountQueue := work.NewQueue("account")
importsQueue := work.NewQueue("imports")
webhookQueue := webhooks.NewQueue(schema)
legacyWebhooks := webhooks.NewLegacyQueue()
@ -43,10 +45,16 @@ func main() {
WithMiddleware(
loaders.Middleware,
imports.Middleware(importsQueue),
account.Middleware(accountQueue),
webhooks.Middleware(webhookQueue),
webhooks.LegacyMiddleware(legacyWebhooks),
).
WithSchema(schema, scopes).
WithQueues(importsQueue, webhookQueue.Queue, legacyWebhooks.Queue).
WithQueues(
importsQueue,
accountQueue,
webhookQueue.Queue,
legacyWebhooks.Queue,
).
Run()
}

View File

@ -70,7 +70,7 @@ CREATE TABLE participant (
CREATE TABLE tracker (
id serial PRIMARY KEY,
owner_id integer NOT NULL REFERENCES "user"(id),
owner_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created timestamp without time zone NOT NULL,
updated timestamp without time zone NOT NULL,
name character varying(1024),
@ -113,7 +113,7 @@ CREATE TABLE ticket (
status integer DEFAULT 0 NOT NULL,
resolution integer DEFAULT 0 NOT NULL,
scoped_id integer NOT NULL,
submitter_id integer NOT NULL REFERENCES participant(id),
submitter_id integer NOT NULL REFERENCES participant(id) ON DELETE CASCADE,
authenticity integer DEFAULT 0 NOT NULL,
comment_count integer DEFAULT 0 NOT NULL,
CONSTRAINT uq_ticket_scoped_id_tracker_id UNIQUE (scoped_id, tracker_id),
@ -135,8 +135,8 @@ CREATE TABLE ticket_assignee (
created timestamp without time zone NOT NULL,
updated timestamp without time zone NOT NULL,
ticket_id integer NOT NULL REFERENCES ticket(id) ON DELETE CASCADE,
assignee_id integer NOT NULL REFERENCES "user"(id),
assigner_id integer NOT NULL REFERENCES "user"(id),
assignee_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
assigner_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
CONSTRAINT idx_ticket_assignee_unique UNIQUE (ticket_id, assignee_id)
);
@ -162,7 +162,7 @@ CREATE INDEX ticket_comment_ticket_id ON ticket_comment USING btree (ticket_id);
CREATE TABLE ticket_label (
ticket_id integer NOT NULL REFERENCES ticket(id) ON DELETE CASCADE,
label_id integer NOT NULL REFERENCES label(id) ON DELETE CASCADE,
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,
PRIMARY KEY (ticket_id, label_id)
);
@ -175,7 +175,7 @@ CREATE TABLE ticket_subscription (
updated timestamp without time zone NOT NULL,
ticket_id integer REFERENCES ticket(id) ON DELETE CASCADE,
tracker_id integer REFERENCES tracker(id) ON DELETE CASCADE,
participant_id integer REFERENCES participant(id),
participant_id integer REFERENCES participant(id) ON DELETE CASCADE,
CONSTRAINT subscription_ticket_participant_uq UNIQUE (ticket_id, participant_id),
CONSTRAINT subscription_tracker_participant_uq UNIQUE (tracker_id, participant_id)
);
@ -192,8 +192,8 @@ CREATE TABLE event (
comment_id integer REFERENCES ticket_comment(id) ON DELETE CASCADE,
label_id integer REFERENCES label(id) ON DELETE CASCADE,
from_ticket_id integer REFERENCES ticket(id) ON DELETE CASCADE,
participant_id integer REFERENCES participant(id),
by_participant_id integer REFERENCES participant(id)
participant_id integer REFERENCES participant(id) ON DELETE CASCADE,
by_participant_id integer REFERENCES participant(id) ON DELETE CASCADE
);
CREATE INDEX event_comment_id ON event USING btree (comment_id);
@ -210,7 +210,7 @@ CREATE TABLE event_notification (
id serial PRIMARY KEY,
created timestamp without time zone NOT NULL,
event_id integer NOT NULL REFERENCES event(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES "user"(id)
user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE
);
CREATE INDEX event_notification_event_id ON event_notification USING btree (event_id);
@ -228,7 +228,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'::auth_method]))),
CONSTRAINT gql_user_wh_sub_check
@ -267,7 +267,7 @@ CREATE TABLE gql_tracker_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,
tracker_id integer NOT NULL REFERENCES tracker(id) ON DELETE CASCADE,
CONSTRAINT gql_tracker_wh_sub_auth_method_check
CHECK ((auth_method = ANY (ARRAY['OAUTH2'::auth_method, 'INTERNAL'::auth_method]))),
@ -307,7 +307,7 @@ CREATE TABLE gql_ticket_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,
tracker_id integer NOT NULL REFERENCES tracker(id) ON DELETE CASCADE,
ticket_id integer NOT NULL REFERENCES ticket(id) ON DELETE CASCADE,
scoped_id integer NOT NULL,
@ -346,7 +346,7 @@ CREATE TABLE oauthtoken (
token_hash character varying(128) NOT NULL,
token_partial character varying(8) NOT NULL,
scopes character varying(512) NOT NULL,
user_id integer REFERENCES "user"(id)
user_id integer REFERENCES "user"(id) ON DELETE CASCADE
);
-- Legacy webhooks (TODO: Remove)
@ -355,8 +355,8 @@ CREATE TABLE user_webhook_subscription (
created timestamp without time zone NOT NULL,
url character varying(2048) NOT NULL,
events character varying NOT NULL,
user_id integer REFERENCES "user"(id),
token_id integer REFERENCES oauthtoken(id)
user_id integer REFERENCES "user"(id), ON DELETE CASCADE
token_id integer REFERENCES oauthtoken(id) ON DELETE CASCADE
);
CREATE TABLE user_webhook_delivery (
@ -370,7 +370,7 @@ CREATE TABLE user_webhook_delivery (
response character varying(65536),
response_status integer NOT NULL,
response_headers character varying(16384),
subscription_id integer NOT NULL REFERENCES user_webhook_subscription(id)
subscription_id integer NOT NULL REFERENCES user_webhook_subscription(id) ON DELETE CASCADE
);
CREATE TABLE tracker_webhook_subscription (

View File

@ -0,0 +1,50 @@
"""Update relationship cascades
Revision ID: 8f822cabf78b
Revises: 4c70fa9bc46f
Create Date: 2022-11-01 15:54:11.371274
"""
# revision identifiers, used by Alembic.
revision = '8f822cabf78b'
down_revision = '4c70fa9bc46f'
from alembic import op
import sqlalchemy as sa
cascades = [
("tracker", "user", "owner_id", "CASCADE"),
("ticket", "participant", "submitter_id", "CASCADE"),
("ticket_assignee", "user", "assignee_id", "CASCADE"),
("ticket_assignee", "user", "assigner_id", "CASCADE"),
("ticket_label", "user", "user_id", "CASCADE"),
("ticket_subscription", "participant", "participant_id", "CASCADE"),
("event", "participant", "participant_id", "CASCADE"),
("event", "participant", "by_participant_id", "CASCADE"),
("event_notification", "user", "user_id", "CASCADE"),
("gql_user_wh_sub", "user", "user_id", "CASCADE"),
("gql_tracker_wh_sub", "user", "user_id", "CASCADE"),
("gql_ticket_wh_sub", "user", "user_id", "CASCADE"),
("oauthtoken", "user", "user_id", "CASCADE"),
("user_webhook_subscription", "user", "user_id", "CASCADE"),
("user_webhook_subscription", "oauthtoken", "token_id", "CASCADE"),
("user_webhook_delivery", "user_webhook_subscription", "subscription_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);
""")