Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use a special deleted journalist user for associations with deleted users #6192

Closed
eloquence opened this issue Dec 8, 2021 · 4 comments · Fixed by #6225
Closed

Use a special deleted journalist user for associations with deleted users #6192

eloquence opened this issue Dec 8, 2021 · 4 comments · Fixed by #6225
Assignees
Milestone

Comments

@eloquence
Copy link
Member

eloquence commented Dec 8, 2021

As discussed in #5467 and #5503, we currently delete rows for deleted journalist users. After deletion, journalist_id will be NULL in other tables that reference the deleted user (e.g., replies and the seen tables; see the database diagram). The API will return the username deleted and the UUID deleted wherever these users are referenced.

There are a few issues with this:

  • It creates an avoidable inconsistency between the data's internal representation and its API representation, which makes it harder to reason about where the data returned by the API originates
  • We cannot rely on the /users endpoint of the API to get a complete list of users that an API consumer may need to associate data with
  • User management in the SecureDrop Client does not map directly to user management on the server

In #5467, we considered preserving a record for each user, while removing all data about the user. This may be desirable in the long run (alongside features such as user locking: #3926), but even if we do this, we will need a migration for historical data. The solution we've settled on for now is to create a global deleted user. A database migration will need to a) create this user, b) associate records currently associated with NULL users with this new user.

This puts us in a good position to make other changes like #5467 in future, and unblocks further work on SecureDrop Client features that rely on journalist attribution.

@eloquence eloquence changed the title Use a special deleted journalist user to associate deleted content Use a special deleted journalist user for associations with deleted users Dec 8, 2021
@sssoleileraaa
Copy link
Contributor

sssoleileraaa commented Jan 4, 2022

I think there are two approaches, but first here are some notes (recap):

Notes

  • The endpoint /get_all_replies tells us which replies exist on the server (so that we can add, update, or delete reply records accordingly to match the server), and which journalists have seen each reply (so that we can add seen records accordingly- note that we decided early on that we only delete seen records when the source or conversation are deleted).

  • The only reason a reply should be updated is because the reply filename, size, or journalist_id has changed. (We can remove the update logic around journalist_id if we go with Option B take below.)

  • The endpoint only sends UUIDs (IDs are partial PII), which is why "deleted" is a hard-coded string for journalist_uuid in the API endpoint

  • [ISSUE] The endpoint does not hard-code a "deleted" string when returning the list of journalist UUIDs in the seen_by field, which means that a SeenReply record with a NULL journalist_id would exist in the seen_replies table (this is also true for SeenMessage and SeenFile), but the endpoint would never report it. Downstream, the client has to ignore this (usually we we rely on remote data from the API to ensure or confirm that we match the server). Overall, the API is inconsistent with how it treats data associated with deleted users.

Option A

  1. Create a deleted user account.
  2. When a user account is deleted, reassociate that user's replies and seen* records to point to the deleted user.
  3. Do the data migration where journalist_id is NULL to point to the new deleted user account.
  4. This automatically fixes the issue described above and the API will work without change. (It should be cleaned up so that we no longer hard-code the "deleted" string.)

Option B

  1. On the client, rely on the new /users endpoint to delete user accounts (we need to do this anyway), which will update the replies and seen* tables to match the server with NULL journalist_ids (this is default behaviour for foreign keys).

  2. No longer create user accounts when we see new journalist_uuids for replies (again, we were going to do this anyway).

    No client data migration is needed because steps 1 and 2 will ensure that we delete the local "deleted" user account, and journalist_ids will be updated to NULL by default.

  3. Fix the issue above by updating the Journalist API so that it returns the same "deleted" string for journalist_uuid in the seen_by field.

@zenmonkeykstop zenmonkeykstop added this to the 2.2.0 milestone Jan 6, 2022
@legoktm
Copy link
Member

legoktm commented Jan 6, 2022

I looked into this a bit, my personal preference would be to have the placeholder "deleted" user (Option A), just so we can rely on journalist_id being type uuid rather than Optional[uuid] (sidenote, MediaWiki takes a similar approach, "deleting" users by merging them into one named "Anonymous").

A bit more concrete proposal: Rather than calling db.session.delete(journalist), we could add a delete(session) method on Journalist, that would scan through whatever tables we want to keep data for, updating the journalist_id to point to the "deleted" user instead. All other models would set cascade="delete" since we want to drop the data on journalist deletion (e.g. journalist_login_attempt, revoked_tokens, etc.). The main downside of this is if someone does use db.session.delete(journalist) we're going to have inconsistent state again.

The migration step would basically do the same thing, look for NULL journalist_ids, either assigning them to the "deleted" user or deleting them.

Another option is to set an event to listen for deletion attempts (https://docs.sqlalchemy.org/en/13/orm/session_events.html), and intercept them to update the seen/reply models to point to "deleted". This seems more much more complex. Also the system was changed in 1.4, so we might have to update/migrate it when upgrading sqlalchemy.

@sssoleileraaa
Copy link
Contributor

Thanks for joining the conversation @legoktm! Option A is part of what I've been advocating for for at least a year to resolve downstream issues (the other part being: #5467). The reason I mention Option B is that it allows us to avoid the data migration and to continue relying on the DB to null out the journalist IDs upon account deletion. It is worth considering in case more issues come up with Option A. Either way, Read Receipts is partially unblocked, so I'll head over that way while you work on this Github Issue and iron out even more details with @zenmonkeykstop.

@legoktm
Copy link
Member

legoktm commented Jan 12, 2022

I pushed my current WIP to the "delete-journalist" branch, so far it lazily creates a "deleted" journalist and reassigns the seen rows to it upon deletion (or so I think, I'll do more testing tomorrow).

legoktm added a commit that referenced this issue Jan 12, 2022
From what I can tell, nearly all the calls to db.session.flush() have
been introduced so that a newly created object's autoincrement ID can be
referenced in a second object to be created, most often this is creating
a Reply, and then a corresponding SeenReply.

However, if we just pass the Reply object into SeenReply, rather than
the id (which is None if it hasn't been flushed), SQLAlchemy takes care
of it all for us, properly assigning the correct autoincrement ID using
the specified relationship.

The motivation for doing this is that JournalistLoginAttempt in loaddata
was not properly flushing and was being assigned to a
journalist_id=NULL, which will soon be an error as part of #6192. Rather
than stick another flush in to fix that case, we can just get rid of
nearly all of them.

There is still one explicit flush() left in loaddata.py's
record_source_interaction(), but that's a different pattern than these
so I've left it alone for now.
legoktm added a commit that referenced this issue Jan 13, 2022
…users

Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into and has no passphase set. It is not
possible to delete it nor is it shown in the admin listing of
journalists. It is lazily created on demand using a special
DeletedJournalist subclass that bypasses username and passphrase
validation.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Jan 13, 2022
…users

Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into and has no passphase set. It is not
possible to delete it nor is it shown in the admin listing of
journalists. It is lazily created on demand using a special
DeletedJournalist subclass that bypasses username and passphrase
validation.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Jan 14, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into and has no passphase set. It is not
possible to delete it nor is it shown in the admin listing of
journalists. It is lazily created on demand using a special
DeletedJournalist subclass that bypasses username and passphrase
validation.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Jan 14, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into and has no passphase set. It is not
possible to delete it nor is it shown in the admin listing of
journalists. It is lazily created on demand using a special
DeletedJournalist subclass that bypasses username and passphrase
validation.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Jan 14, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into and has no passphase set. It is not
possible to delete it nor is it shown in the admin listing of
journalists. It is lazily created on demand using a special
DeletedJournalist subclass that bypasses username and passphrase
validation.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Jan 14, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into and has no passphase set. It is not
possible to delete it nor is it shown in the admin listing of
journalists. It is lazily created on demand using a special
DeletedJournalist subclass that bypasses username and passphrase
validation.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Jan 18, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into and has no passphase set. It is not
possible to delete it nor is it shown in the admin listing of
journalists. It is lazily created on demand using a special
DeletedJournalist subclass that bypasses username and passphrase
validation.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Jan 19, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into and has no passphase set. It is not
possible to delete it nor is it shown in the admin listing of
journalists. It is lazily created on demand using a special
DeletedJournalist subclass that bypasses username and passphrase
validation.

Because we now have a real user to reference, the api.single_reply
endpoint will return a real UUID instead of the "deleted" placeholder.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Jan 21, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into and has no passphase set. It is not
possible to delete it nor is it shown in the admin listing of
journalists. It is lazily created on demand using a special
DeletedJournalist subclass that bypasses username and passphrase
validation.

Because we now have a real user to reference, the api.single_reply
endpoint will return a real UUID instead of the "deleted" placeholder.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Jan 21, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into and has no passphase set. It is not
possible to delete it nor is it shown in the admin listing of
journalists. It is lazily created on demand using a special
DeletedJournalist subclass that bypasses username and passphrase
validation.

Because we now have a real user to reference, the api.single_reply
endpoint will return a real UUID instead of the "deleted" placeholder.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Jan 21, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into and has no passphase set. It is not
possible to delete it nor is it shown in the admin listing of
journalists. It is lazily created on demand using a special
DeletedJournalist subclass that bypasses username and passphrase
validation.

Because we now have a real user to reference, the api.single_reply
endpoint will return a real UUID instead of the "deleted" placeholder.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Jan 31, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into. It is not possible to delete it nor
is it shown in the admin listing of journalists. It is lazily created on
demand using a special DeletedJournalist subclass that bypasses username
validation. For consistency reasons, a randomly generated diceware
passphrase and TOTP secret are set in the database for this account, but
never revealed to anyone.

Because we now have a real user to reference, the api.single_reply
endpoint will return a real UUID instead of the "deleted" placeholder.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Jan 31, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into. It is not possible to delete it nor
is it shown in the admin listing of journalists. It is lazily created on
demand using `Journalist.get_deleted()`. For consistency reasons, a
randomly generated diceware passphrase and TOTP secret are set in the
database for this account, but never revealed to anyone. We use a
placeholder username to bypass Journalist's normal validation check that
the username is not "deleted".

Because we now have a real user to reference, the api.single_reply
endpoint will return a real UUID instead of the "deleted" placeholder.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Feb 1, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into. It is not possible to delete it nor
is it shown in the admin listing of journalists. It is lazily created on
demand using `Journalist.get_deleted()`. For consistency reasons, a
randomly generated diceware passphrase and TOTP secret are set in the
database for this account, but never revealed to anyone. We use a
placeholder username to bypass Journalist's normal validation check that
the username is not "deleted".

Because we now have a real user to reference, the api.single_reply
endpoint will return a real UUID instead of the "deleted" placeholder.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
legoktm added a commit that referenced this issue Feb 1, 2022
Currently when a journalist is deleted, most referential tables are
updated with `journalist_id=NULL`, forcing all clients to accomodate
that case. Instead, we are now going to either re-associate those rows
with a special "deleted" journalist account, or delete them outright.
All journalist_id columns are now NOT NULL to enforce this.

Tables with rows migrated to "deleted":
* replies
* seen_files
* seen_messages
* seen_replies

Tables with rows that are deleted outright:
* journalist_login_attempt
* revoked_tokens

The "deleted" journalist account is a real account that exists in the
database, but cannot be logged into. It is not possible to delete it nor
is it shown in the admin listing of journalists. It is lazily created on
demand using `Journalist.get_deleted()`. For consistency reasons, a
randomly generated diceware passphrase and TOTP secret are set in the
database for this account, but never revealed to anyone. We use a
placeholder username to bypass Journalist's normal validation check that
the username is not "deleted".

Because we now have a real user to reference, the api.single_reply
endpoint will return a real UUID instead of the "deleted" placeholder.

Journalist objects must now be deleted by calling the new delete()
function on them. Trying to directly `db.session.delete(journalist)`
will most likely fail with an Exception because of rows that weren't
migrated first.

The migration step looks for any existing rows in those tables with
journalist_id=NULL and either migrates them to "deleted" or deletes
them. Then all the columns are changed to be NOT NULL.

Fixes #6192.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants