Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions pkg/core/config/upgrades.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,31 @@ type UpgradeSchedule struct {
// activation point: the migration replay seeds a cid for every track that
// exists.
ContentAuthEnforcementHeight int64

// ContentAuthStrictHeight closes the first-assertion window that
// ContentAuthEnforcementHeight opens. Between the two heights a cid nobody
// holds goes to whoever asserts it first; from this height a Track
// create/update may only assert cids the content-auth state already
// records as the acting user's, and a cid nobody holds is refused.
//
// The window exists for the genesis migration: the API flusher replays
// old-chain track writes naming cids nobody attested (old-chain storage
// never did, and the replay seeds only the snapshot's tracks) and stalls
// on the first refused row. It is not safe to leave open — the upload row
// is public while it transcodes and the attestation is visible in the
// mempool before it lands, so anyone who reads a cid there can take the
// claim by naming it first. Schedule strict as soon as the flusher has
// drained; never before, or it stalls.
ContentAuthStrictHeight int64
}

// Rules is the resolved rule set for a single height: a flat description of
// active behaviors with no heights in sight.
type Rules struct {
AuthEnforced bool
ContentAuthEnforced bool
// ContentAuthStrict only means anything with ContentAuthEnforced.
ContentAuthStrict bool
}

// RulesetAt resolves the rules governing the given block height. A nil
Expand All @@ -65,6 +83,7 @@ func (u *UpgradeSchedule) RulesetAt(height int64) Rules {
return Rules{
AuthEnforced: activeAt(u.AuthEnforcementHeight, height),
ContentAuthEnforced: activeAt(u.ContentAuthEnforcementHeight, height),
ContentAuthStrict: activeAt(u.ContentAuthStrictHeight, height),
}
}

Expand All @@ -82,11 +101,13 @@ var upgradeSchedules = map[string]*UpgradeSchedule{
"openaudio-devnet": {
AuthEnforcementHeight: 1,
ContentAuthEnforcementHeight: 1,
ContentAuthStrictHeight: 1,
},
// sandbox
"audius-devnet": {
AuthEnforcementHeight: 1,
ContentAuthEnforcementHeight: 1,
ContentAuthStrictHeight: 1,
},
// stage
"audius-testnet-alpha": {},
Expand Down
26 changes: 25 additions & 1 deletion pkg/core/config/upgrades_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ func TestRulesetAtZeroMeansNever(t *testing.T) {
}
}

// Strict content auth is a second height over the same gate: enforcement
// alone opens the first-assertion window, strict closes it, and a schedule
// that activates both at one height never opens it.
func TestContentAuthStrictCloseWindow(t *testing.T) {
u := &UpgradeSchedule{ContentAuthEnforcementHeight: 10, ContentAuthStrictHeight: 20}
if r := u.RulesetAt(9); r.ContentAuthEnforced || r.ContentAuthStrict {
t.Fatal("nothing should be active before enforcement")
}
if r := u.RulesetAt(15); !r.ContentAuthEnforced || r.ContentAuthStrict {
t.Fatal("between the heights the window is open: enforced, not strict")
}
if r := u.RulesetAt(20); !r.ContentAuthEnforced || !r.ContentAuthStrict {
t.Fatal("at the strict height the window is closed")
}
same := &UpgradeSchedule{ContentAuthEnforcementHeight: 1, ContentAuthStrictHeight: 1}
if r := same.RulesetAt(1); !r.ContentAuthEnforced || !r.ContentAuthStrict {
t.Fatal("equal heights must be strict from the first enforced block")
}
}

// A nil schedule (tests, manually built configs) resolves to no active
// upgrades instead of panicking.
func TestRulesetAtNilSchedule(t *testing.T) {
Expand All @@ -40,9 +60,13 @@ func TestRulesetAtNilSchedule(t *testing.T) {
// must never inherit another network's activations.
func TestScheduleForChainID(t *testing.T) {
for _, chainID := range []string{"openaudio-devnet", "audius-devnet"} {
if !ScheduleForChainID(chainID).RulesetAt(1).AuthEnforced {
r := ScheduleForChainID(chainID).RulesetAt(1)
if !r.AuthEnforced {
t.Fatalf("%s: auth enforcement should be active from height 1", chainID)
}
if !r.ContentAuthEnforced || !r.ContentAuthStrict {
t.Fatalf("%s: content auth should be strict from height 1, got %+v", chainID, r)
}
}
for _, chainID := range []string{"audius-testnet-alpha", "audius-mainnet-alpha-beta", "some-future-chain"} {
if ScheduleForChainID(chainID).RulesetAt(1<<40) != (Rules{}) {
Expand Down
2 changes: 1 addition & 1 deletion pkg/core/server/abci.go
Original file line number Diff line number Diff line change
Expand Up @@ -1332,7 +1332,7 @@ func (s *Server) finalizeTransaction(ctx context.Context, req *abcitypes.Finaliz
case *v1.SignedTransaction_Plays:
return s.finalizePlayTransaction(ctx, msg)
case *v1.SignedTransaction_ManageEntity:
return s.finalizeManageEntity(ctx, msg)
return s.finalizeManageEntity(ctx, msg, blockHeight)
case *v1.SignedTransaction_ManageEntityMigration:
return s.finalizeManageEntityMigration(ctx, msg)
case *v1.SignedTransaction_Attestation:
Expand Down
4 changes: 2 additions & 2 deletions pkg/core/server/auth_enforcement.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ func (s *Server) validateManageEntityAuth(ctx context.Context, rules config.Rule
// cid it has no claim to is rejected without leaving the entity behind in
// the overlay for later transactions in the proposal to build on.
if rules.ContentAuthEnforced {
if err := validateTrackContentAuth(ctx, overlay, tx); err != nil {
if err := validateTrackContentAuth(ctx, overlay, tx, rules); err != nil {
if isAuthValidationError(err) {
return &authRejectionError{reason: err.Error()}
}
return err
}
}

err = applyAuthProjection(ctx, overlay, tx)
err = applyAuthProjection(ctx, overlay, tx, rules)
if err == nil {
return nil
}
Expand Down
26 changes: 23 additions & 3 deletions pkg/core/server/auth_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1"
"github.com/OpenAudio/go-openaudio/pkg/core/config"
"github.com/OpenAudio/go-openaudio/pkg/core/db"
)

Expand Down Expand Up @@ -286,7 +287,11 @@ func validateAuthSigner(ctx context.Context, st authReader, userID int64, signer
// is not auth-tracked at all — the projection has no opinion there. An
// authValidationError is the projection mirroring an ETL rejection; any other
// error is a store failure.
func applyAuthProjection(ctx context.Context, st authStore, tx authTx) error {
//
// rules is the ruleset resolved for the height the transaction executes at.
// The projection branches on it only for the first-assertion cid claim
// (content_auth_state.go); migration rows ignore it.
func applyAuthProjection(ctx context.Context, st authStore, tx authTx, rules config.Rules) error {
entityType, ok := canonicalAuthEntityType(tx.EntityType)
if !ok {
return nil
Expand All @@ -310,7 +315,21 @@ func applyAuthProjection(ctx context.Context, st authStore, tx authTx) error {
// and cids left unseeded there make enforcement unactivatable. Runs
// only after the entity itself projects: a track that was skipped must
// not leave claims behind.
return projectMigratedTrackCids(ctx, st, tx)
if err := projectMigratedTrackCids(ctx, st, tx); err != nil {
return err
}
return projectAssertedTrackCids(ctx, st, tx, rules)
case entityType == authEntityTypeTrack && action == authActionUpdate:
// Track updates are otherwise untracked (ownership on update is the
// ETL's rule); the only projection is the first-assertion claim, and
// it requires the same signer authority a create does.
if tx.Migration || !rules.ContentAuthEnforced || rules.ContentAuthStrict {
return nil
}
if err := validateAuthSigner(ctx, st, tx.UserID, tx.Signer); err != nil {
return err
}
return projectAssertedTrackCids(ctx, st, tx, rules)
case (entityType == authEntityTypeTrack || entityType == authEntityTypePlaylist) && action == authActionDelete:
return projectEntityDelete(ctx, st, tx, entityType)
case entityType == authEntityTypeGrant && action == authActionCreate:
Expand Down Expand Up @@ -629,7 +648,8 @@ func projectAppDelete(ctx context.Context, st authStore, tx authTx) error {
// it, with reason describing why. A migration replays state the source system
// already accepted, so any skip is a defect the caller should surface.
func ProjectMigrationAuthState(ctx context.Context, q *db.Queries, me *v1.ManageEntityLegacyMigration) (skipped bool, reason string, err error) {
err = applyAuthProjection(ctx, &dbAuthStore{q: q}, authTxFromManageEntityMigration(me))
// A migration row's projection does not branch on the ruleset.
err = applyAuthProjection(ctx, &dbAuthStore{q: q}, authTxFromManageEntityMigration(me), config.Rules{})
switch {
case err == nil:
return false, "", nil
Expand Down
23 changes: 20 additions & 3 deletions pkg/core/server/auth_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ import (
"testing"

v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1"
"github.com/OpenAudio/go-openaudio/pkg/core/config"
)

// Rulesets the projection tests run under. Most tests do not care and use
// the zero ruleset (pre-gate); the content-auth tests pick a side of the
// first-assertion window explicitly.
var (
// windowRules: content auth enforced, not yet strict — an unclaimed cid
// goes to the first asserter.
windowRules = config.Rules{AuthEnforced: true, ContentAuthEnforced: true}
// strictRules: the steady state — every cid must already be claimed.
strictRules = config.Rules{AuthEnforced: true, ContentAuthEnforced: true, ContentAuthStrict: true}
)

// memAuthStore is a map-backed authStore so projection logic tests run
Expand Down Expand Up @@ -186,14 +198,19 @@ func (m *memAuthStore) SetEntityDeleted(_ context.Context, entityType string, en

func mustProject(t *testing.T, st authStore, tx authTx) {
t.Helper()
if err := applyAuthProjection(context.Background(), st, tx); err != nil {
mustProjectUnder(t, st, tx, config.Rules{})
}

func mustProjectUnder(t *testing.T, st authStore, tx authTx, rules config.Rules) {
t.Helper()
if err := applyAuthProjection(context.Background(), st, tx, rules); err != nil {
t.Fatalf("expected projection to apply: %v", err)
}
}

func mustSkip(t *testing.T, st authStore, tx authTx, wantReason string) {
t.Helper()
err := applyAuthProjection(context.Background(), st, tx)
err := applyAuthProjection(context.Background(), st, tx, config.Rules{})
if err == nil {
t.Fatalf("expected projection to skip (%s), but it applied", wantReason)
}
Expand Down Expand Up @@ -396,7 +413,7 @@ func TestProjectUntrackedPairIsNil(t *testing.T) {
st := newMemAuthStore()
if err := applyAuthProjection(context.Background(), st, authTx{
UserID: 1, EntityType: "Follow", Action: "Create", Signer: "0xw1",
}); err != nil {
}, config.Rules{}); err != nil {
t.Fatalf("untracked pair must project to nil, got %v", err)
}
}
Expand Down
64 changes: 59 additions & 5 deletions pkg/core/server/content_auth_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"

v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1"
"github.com/OpenAudio/go-openaudio/pkg/core/config"
)

// Consensus-side content authorization (core_auth_cids): which user may
Expand Down Expand Up @@ -91,13 +92,26 @@ func projectMigratedTrackCids(ctx context.Context, st authStore, tx authTx) erro
}

// validateTrackContentAuth is the enforcement check, active only under
// Rules.ContentAuthEnforced: every cid the transaction asserts must be recorded
// to a wallet authorized to act for the writing user.
// Rules.ContentAuthEnforced: no cid the transaction asserts may be held by a
// user other than the writing user, and once Rules.ContentAuthStrict is
// active every cid must be held by the writing user.
//
// Between the two heights a cid nobody holds passes, and
// the projection then records it to the writer (projectAssertedTrackCids):
// first assertion wins. That window exists for the genesis migration, whose
// API flusher replays old-chain track writes naming cids nobody attested and
// stalls on the first refused row. It is not safe to leave open: the upload
// row is public while it transcodes and the attestation is visible in the
// mempool before it lands, so whoever reads a cid there can take the claim by
// naming it first. Strict closes it once the flusher drains, and from then on
// every cid a track may legitimately name is already claimed — migrated
// tracks are seeded by the replay and uploads to an enforcing node are
// attested before the upload reads done.
//
// Only cids present in this transaction are checked, so metadata-only edits on
// a track whose audio predates the projection keep working, and an audio
// replacement is checked against the new cid alone.
func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx) error {
func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx, rules config.Rules) error {
if tx.Migration || tx.EntityType != authEntityTypeTrack {
return nil
}
Expand Down Expand Up @@ -130,10 +144,50 @@ func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx) err
if err != nil {
return err
}
if !known {
if known {
return authValidationErrorf("%s %q was not uploaded for user %d", key, cid, tx.UserID)
}
// Only reached under ContentAuthEnforced, so strict alone decides.
if rules.ContentAuthStrict {
return authValidationErrorf("%s %q is not attested to any uploader", key, cid)
}
return authValidationErrorf("%s %q was not uploaded for user %d", key, cid, tx.UserID)
}
return nil
}

// projectAssertedTrackCids records the writing user as claimant of every cid
// the transaction names that nobody holds yet — the projection half of
// first-assertion-wins (see validateTrackContentAuth). A cid someone already
// holds is left alone: at proposal time validation has rejected the write,
// and at finalize a block from a proposer without the gate must not hand a
// claim to whoever named the cid.
//
// Runs only for live writes inside the first-assertion window: content auth
// enforced but not yet strict. Unlike validation this runs for every live
// write, so the enforced check is explicit — before the gate a chain must
// accumulate no claims from live traffic, or activating enforcement later
// would trust state built from unverified assertions. Under strict there is
// nothing unclaimed to record. Migration rows are seeded by
// projectMigratedTrackCids.
func projectAssertedTrackCids(ctx context.Context, st authStore, tx authTx, rules config.Rules) error {
if tx.Migration || !rules.ContentAuthEnforced || rules.ContentAuthStrict || tx.EntityType != authEntityTypeTrack {
return nil
}
for _, key := range trackCidMetadataKeys {
cid := tx.metaString(key)
if cid == "" {
continue
}
known, err := st.CidIsClaimed(ctx, cid)
if err != nil {
return err
}
if known {
continue
}
if err := st.InsertCid(ctx, cid, tx.UserID, ""); err != nil {
return err
}
}
return nil
}
Loading