Skip to content

replicate the auth.db file to secondaries - #3057

Open
zachmu wants to merge 5 commits into
mainfrom
zachmu/replicate-auth
Open

replicate the auth.db file to secondaries#3057
zachmu wants to merge 5 commits into
mainfrom
zachmu/replicate-auth

Conversation

@zachmu

@zachmu zachmu commented Aug 8, 2026

Copy link
Copy Markdown
Member

This closes a significant gap in replication.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor
Main PR
covering_index_scan_postgres 2038.08/s 2055.59/s +0.8%
groupby_scan_postgres 153.75/s 150.89/s -1.9%
index_join_postgres 669.17/s 657.68/s -1.8%
index_join_scan_postgres 840.74/s 836.07/s -0.6%
index_scan_postgres 31.99/s 31.89/s -0.4%
oltp_delete_insert_postgres 806.08/s 783.99/s -2.8%
oltp_insert 717.06/s 722.63/s +0.7%
oltp_point_select 3451.84/s 3404.86/s -1.4%
oltp_read_only 3411.57/s 3405.04/s -0.2%
oltp_read_write 2540.85/s 2560.16/s +0.7%
oltp_update_index 752.35/s 757.77/s +0.7%
oltp_update_non_index 803.38/s 795.27/s -1.1%
oltp_write_only 1822.09/s 1827.60/s +0.3%
select_random_points 2109.64/s 2144.98/s +1.6%
select_random_ranges 1595.04/s 1566.96/s -1.8%
table_scan_postgres 31.79/s 31.85/s +0.1%
types_delete_insert_postgres 833.21/s 815.22/s -2.2%
types_table_scan_postgres 14.37/s 14.25/s -0.9%

@itoqa

itoqa Bot commented Aug 8, 2026

Copy link
Copy Markdown

Ito QA test results
Commit: 6b6cfe7: 19 test cases ran, 1 failed ❌, 17 passed ✅, 1 additional finding ⚠️.

Summary

The run broadly covers authentication and authorization lifecycle behavior, including role and privilege changes, persistence, replication, startup, and failover, with both normal user flows and adversarial cases such as unauthorized changes and malformed state. It also exercises edge cases around expiry handling and preserving authorization state across distributed transitions.

Not safe to merge yet — a PR-attributable malformed authorization-state case can crash the server, making this a high-severity availability and access-control risk at the replication boundary. An unrelated medium-severity expiry-date issue remains as a flag for later, but is not a merge driver.

Tests run by Ito

View full run

Result Severity Type Description
High severity Standby The standby does not safely reject every malformed supported-version payload. Short payloads and unknown versions return errors, but a truncated version 0 or version 1 payload can make the server panic during decoding.
Auth After the server restarted, the writer account still existed, the new password worked, and the revoked table access stayed blocked.
Auth Creating users and roles, granting and revoking access, adding and removing role membership, and dropping temporary roles all worked. The temporary roles were gone from the final role list.
Auth Unauthorized users could not change or remove the protected role. Missing roles returned the expected error, while IF EXISTS skipped missing roles and removed only the intended role.
Cluster The primary and standby started successfully. A reader account created on the primary could connect to the standby and use its SELECT access.
Cluster The server started without trying to recreate the existing database, and the saved login state remained usable afterward.
Cluster The read-only standby finished starting without failing on default database creation. The replicated reader role and its authorization remained usable on the standby.
Cluster Sessions opened during startup and after initialization saw the same seeded authorization state.
Cluster A role and its permission were still available on the standby after the initial state finished loading.
Failover The promoted database accepted the replicated writer, returned the existing rows, and saved new rows after failover.
Failover After failover, the changed password worked and the deleted role could not log in. The promoted server kept the latest authorization state.
Failover After the standby became the writer, the changed password worked, the old password was rejected, the revoked table action stayed blocked, and the retained table access still worked.
Replication Creating a new login on the primary copied the role to the standby, where the new login could authenticate and use its replicated access.
Replication The standby received both privileges, then removed only INSERT after it was revoked on the primary. SELECT on vals still worked, so unrelated access was preserved.
Replication Changing pwuser's password on the primary was copied to the standby. The old password was rejected there, and the changed password allowed login.
Replication The concurrent privilege scenario could not run because the fixture did not implement it and the local target environment was unavailable. Source review shows grant and revoke changes are serialized under the authorization write lock, so this run found no application bug.
Standby A valid auth state was replicated to the standby, where the new role, login, and table access all worked.
Standby Loading saved roles with gaps in their identity numbers does not cause the next role to replace an existing role. The allocator moves past the largest saved identity before creating a new login.
⚠️ Medium severity Auth The valid ALTER ROLE statement returns 'could not parse until' instead of updating the role. The final role readback shows a blank expiry value; malformed timestamps and an unknown option are rejected as expected.
Additional Findings Details

These findings are unrelated to the current changes but were observed during testing.

🟡 Valid role expiry dates are rejected
  • Severity: Medium Medium severity
  • Description: The valid ALTER ROLE statement returns 'could not parse until' instead of updating the role. The final role readback shows a blank expiry value; malformed timestamps and an unknown option are rejected as expected.
  • Impact: Admins cannot set an expiry date for a database role through the supported SQL command. The role remains active without the intended expiry until it is fixed or changed through another method.
  • Steps to Reproduce:
    1. Create or use a login role such as pwuser.
    2. Run ALTER ROLE pwuser VALID UNTIL '2030-01-01 00:00:00+00'.
    3. Query the role's expiry value in pg_roles.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: The runtime result is a production behavior failure, not a harness-only assertion: the exact valid SQL statement was sent to the local Doltgres node and returned 'parsing as type timestamp: could not parse "until"', while pg_roles retained a null rolvaliduntil. The relevant existing execution path in server/ast/alter_role.go:99-110 accepts the VALID_UNTIL option and passes its DString value into the injected server/node.AlterRole statement. server/node/alter_role.go:150-164 then converts the received string with pgtypes.TimestampTZ.IoInput, clears NULL values, and rejects unknown options before persistence at lines 166-175. Because the error says the word 'until' is being parsed as the timestamp rather than the supplied date, the defect is upstream in the SQL parser/AST value construction or its handling of the two-word VALID UNTIL option, before the execution code can receive the intended timestamp. The smallest practical fix is to correct that parser/AST conversion so VALID UNTIL supplies the quoted timestamp value to the existing TimestampTZ conversion, then add a regression test for a real date and NULL clearing. The PR diff modifies server/node/alter_role.go only at the persistence call and replication wait (around lines 164-175); it does not change the VALID_UNTIL conversion logic or the parser path, so those replication changes are not a direct cause.
Evidence Package

Tip

Reply with @itoqa to send us feedback on this test run.

Comment thread server/auth/cluster.go
return nil
}

// OverwriteDatabase replaces the entire contents of the auth database, both in memory and on disk, with the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

View All Evidence

High severity Malformed auth data can crash a standby

What failed: The standby does not safely reject every malformed supported-version payload. Short payloads and unknown versions return errors, but a truncated version 0 or version 1 payload can make the server panic during decoding.

Impact · Steps · Stub / mock · Analysis · Why this is likely a bug
  • Severity: High High severity
  • Impact: A malformed authorization update can crash the server instead of being rejected. Users may lose access to protected services until the server recovers.
  • Steps to Reproduce:
    1. Send a payload whose first four bytes identify version 0 or version 1 but truncate the remaining serialized fields.
    2. Apply the payload through the standby authorization-state replacement operation.
    3. Observe that decoding can panic instead of returning an error; the expected behavior is to reject the payload and leave the prior authorization state unchanged.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: The PR adds OverwriteDatabase in server/auth/cluster.go:70-83. It creates a fresh database, calls fresh.deserialize(data) at lines 75-78, and swaps globalDatabase only after that call succeeds at line 80, which correctly protects the old state from ordinary returned errors but does not protect the process from a panic. The PR also changes server/auth/serialization.go:68-81 to dispatch version 0 and version 1 payloads through deserializeV0 and deserializeV1. Those loaders call Role.deserialize and the privilege deserializers without validating that all encoded fields are present. The underlying reader is unchecked: utils/reader.go:70-73 indexes buf directly for Uint8, lines 76-90 slices the buffer for fixed-width integers, lines 129-192 indexes ahead while decoding variable integers, and lines 195-200 advances by the encoded string length before slicing. A supported-version payload truncated at any of these operations therefore raises a Go bounds panic rather than producing an error for OverwriteDatabase to return. The smallest practical mitigation is to make the overwrite boundary convert malformed decoding panics into errors, while a stronger targeted fix is to add bounds checks to the reader methods used by auth deserialization and propagate those errors. Either approach must keep the global state swap after successful decoding only.
  • Why this is likely a bug: The required contract is to reject malformed supported-version data with an error and preserve the old authorization state. The source path confirms the opposite failure mode: version 0/1 decoding uses unchecked reads, so truncated input can escape the normal error return and panic the process. This is a real production risk at the new replication boundary even though the runtime server was unavailable and the available integration test covers only valid replication. The smallest fix is to guard this boundary or, preferably, return bounds errors from the reader operations used by auth decoding; do not assign globalDatabase or write the malformed bytes unless decoding completes successfully.
Relevant code

server/auth/cluster.go:70-83

func OverwriteDatabase(data []byte) error {
	var err error
	LockWrite(func() {
		fresh := newEmptyDatabase()
		if err = fresh.deserialize(data); err != nil {
			return
		}
		globalDatabase = fresh
		err = WriteSerializedDatabase(data)
	})
	return err
}

server/auth/serialization.go:64-81

func (db *Database) deserialize(data []byte) error {
	if len(data) < 4 {
		return errors.New("invalid auth database format")
	}
	reader := utils.NewReader(data)
	version := reader.Uint32()
	var err error
	switch version {
	case 0:
		err = db.deserializeV0(reader)
	case 1:
		err = db.deserializeV1(reader)
	default:
		return errors.Errorf("Authorization database format %d is not supported, please upgrade Doltgres", version)
	}
	if err != nil {
		return err
	}

utils/reader.go:69-90

func (reader *Reader) Uint8() uint8 {
	reader.offset += 1
	return reader.buf[reader.offset-1]
}

func (reader *Reader) Uint16() uint16 {
	reader.offset += 2
	return binary.BigEndian.Uint16(reader.buf[reader.offset-2:])
}

func (reader *Reader) Uint32() uint32 {
	reader.offset += 4
	return binary.BigEndian.Uint32(reader.buf[reader.offset-4:])
}

func (reader *Reader) Uint64() uint64 {
	reader.offset += 8
	return binary.BigEndian.Uint64(reader.buf[reader.offset-8:])
}

utils/reader.go:195-200

func (reader *Reader) String() string {
	length := reader.VariableUint()
	reader.offset += length
	return string(reader.buf[reader.offset-length : reader.offset])
}
Evidence Package
Copy prompt for an agent
Ito QA identified the following failure during automated PR testing. Please investigate and propose a fix.

**High severity — Malformed auth data can crash a standby**

**What failed:** The standby does not safely reject every malformed supported-version payload. Short payloads and unknown versions return errors, but a truncated version 0 or version 1 payload can make the server panic during decoding.

- **Impact:** A malformed authorization update can crash the server instead of being rejected. Users may lose access to protected services until the server recovers.
- **Steps to reproduce:**
  1. Send a payload whose first four bytes identify version 0 or version 1 but truncate the remaining serialized fields.
  2. Apply the payload through the standby authorization-state replacement operation.
  3. Observe that decoding can panic instead of returning an error; the expected behavior is to reject the payload and leave the prior authorization state unchanged.
- **Stub / mock content:** No stubs, mocks, or bypasses were applied for this test in the recorded run.
- **Code analysis:** The PR adds OverwriteDatabase in server/auth/cluster.go:70-83. It creates a fresh database, calls fresh.deserialize(data) at lines 75-78, and swaps globalDatabase only after that call succeeds at line 80, which correctly protects the old state from ordinary returned errors but does not protect the process from a panic. The PR also changes server/auth/serialization.go:68-81 to dispatch version 0 and version 1 payloads through deserializeV0 and deserializeV1. Those loaders call Role.deserialize and the privilege deserializers without validating that all encoded fields are present. The underlying reader is unchecked: utils/reader.go:70-73 indexes buf directly for Uint8, lines 76-90 slices the buffer for fixed-width integers, lines 129-192 indexes ahead while decoding variable integers, and lines 195-200 advances by the encoded string length before slicing. A supported-version payload truncated at any of these operations therefore raises a Go bounds panic rather than producing an error for OverwriteDatabase to return. The smallest practical mitigation is to make the overwrite boundary convert malformed decoding panics into errors, while a stronger targeted fix is to add bounds checks to the reader methods used by auth deserialization and propagate those errors. Either approach must keep the global state swap after successful decoding only.
- **Why this is likely a bug:** The required contract is to reject malformed supported-version data with an error and preserve the old authorization state. The source path confirms the opposite failure mode: version 0/1 decoding uses unchecked reads, so truncated input can escape the normal error return and panic the process. This is a real production risk at the new replication boundary even though the runtime server was unavailable and the available integration test covers only valid replication. The smallest fix is to guard this boundary or, preferably, return bounds errors from the reader operations used by auth decoding; do not assign globalDatabase or write the malformed bytes unless decoding completes successfully.

**Relevant code:**

`server/auth/cluster.go:70-83`

~~~go
func OverwriteDatabase(data []byte) error {
	var err error
	LockWrite(func() {
		fresh := newEmptyDatabase()
		if err = fresh.deserialize(data); err != nil {
			return
		}
		globalDatabase = fresh
		err = WriteSerializedDatabase(data)
	})
	return err
}
~~~

`server/auth/serialization.go:64-81`

~~~go
func (db *Database) deserialize(data []byte) error {
	if len(data) < 4 {
		return errors.New("invalid auth database format")
	}
	reader := utils.NewReader(data)
	version := reader.Uint32()
	var err error
	switch version {
	case 0:
		err = db.deserializeV0(reader)
	case 1:
		err = db.deserializeV1(reader)
	default:
		return errors.Errorf("Authorization database format %d is not supported, please upgrade Doltgres", version)
	}
	if err != nil {
		return err
	}
~~~

`utils/reader.go:69-90`

~~~go
func (reader *Reader) Uint8() uint8 {
	reader.offset += 1
	return reader.buf[reader.offset-1]
}

func (reader *Reader) Uint16() uint16 {
	reader.offset += 2
	return binary.BigEndian.Uint16(reader.buf[reader.offset-2:])
}

func (reader *Reader) Uint32() uint32 {
	reader.offset += 4
	return binary.BigEndian.Uint32(reader.buf[reader.offset-4:])
}

func (reader *Reader) Uint64() uint64 {
	reader.offset += 8
	return binary.BigEndian.Uint64(reader.buf[reader.offset-8:])
}
~~~

`utils/reader.go:195-200`

~~~go
func (reader *Reader) String() string {
	length := reader.VariableUint()
	reader.offset += length
	return string(reader.buf[reader.offset-length : reader.offset])
}
~~~

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor
Main PR
Total 42090 42090
Successful 18941 18939
Failures 23149 23151
Partial Successes1 5340 5340
Main PR
Successful 45.0012% 44.9964%
Failures 54.9988% 55.0036%

${\color{red}Regressions (2)}$

random

QUERY:          (SELECT unique1 AS random
  FROM onek ORDER BY random() LIMIT 1)
INTERSECT
(SELECT unique1 AS random
  FROM onek ORDER BY random() LIMIT 1)
INTERSECT
(SELECT unique1 AS random
  FROM onek ORDER BY random() LIMIT 1);
RECEIVED ERROR: expected row count 0 but received 1

subselect

QUERY:          select count(*) from tenk1 t
where (exists(select 1 from tenk1 k where k.unique1 = t.unique2) or ten < 0);
RECEIVED ERROR: timeout during Receive

Footnotes

  1. These are tests that we're marking as Successful, however they do not match the expected output in some way. This is due to small differences, such as different wording on the error messages, or the column names being incorrect while the data itself is correct.

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 this pull request may close these issues.

1 participant