Commit Graph
100 Commits
Author SHA1 Message Date
zhangwenjian dd8d89a990 test: make the index probe return a copy, like every real dto.Index does
IndexAction closes over one dto.Index and serves every request to the route
from it; Generate exists so each request gets its own instance, and every
implementation in this repository returns a copy for that reason. The probe
returned the receiver, which made it the one shape IndexAction is not
written against - and inconsistent with probeRow in the same file, which
already copied.

A single-request test cannot tell the two apart, so the assertion is on
Generate itself rather than on the action's behaviour.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:22:12 +08:00
zhangwenjian 2e5b23565e test: cover data permission through a real CRUD action
create.go/delete.go/index.go/update.go/view.go were not lowered to
core (PRD 006 F3) and still call actions.Permission directly, in this
repository, on a code path core's own test suite knows nothing about:
core pins down what Permission builds for a given scope, but nothing
covered whether this package's five Actions still remember to call it
at all. TestIndexActionAppliesDataPermission runs IndexAction exactly
as a real request would, against a real in-memory database, and
inspects the SQL GORM actually executed - not just that the handler
returned success, which it would just as happily do with the filter
missing entirely.

The SQL is captured through a gorm.io/gorm/logger.Interface wrapper
rather than read back from IndexAction's own *gorm.DB: IndexAction
builds and executes its query in one unbroken chain
(Model().Scopes().Find()...Count()) and never hands the built
statement back to its caller, so there is nothing else to inspect it
through.

Counterproof performed and reverted (not part of this commit): with
Permission(object.TableName(), p) removed from IndexAction's Scopes
call, the test failed with the captured SQL carrying no WHERE clause
at all (`SELECT * FROM action_probe_row LIMIT 10`); index.go was then
restored to its committed content (`git diff --exit-code` verified
clean).

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:15:58 +08:00
zhangwenjian f4e3f04d30 refactor🎨: turn common/actions' data permission into a thin forward
DataPermission, PermissionAction, Permission, GetPermissionFromContext,
IsValidDataScope, PermissionKey and the five DataScope* constants now
forward to go-admin-core's sdk/contract/actions, which carries the
already-fixed logic from feat/006-security-prereq (PRD 006 F14/H1-H3).
create.go/delete.go/index.go/update.go/view.go - the five generic CRUD
actions - are untouched: they call Permission and
GetPermissionFromContext by the same names, which now resolve to
forwards with identical behaviour, and stay in this package rather
than moving to core (PRD 006 F3: core's exports are a permanent
promise every fork inherits, and CRUD shape is this framework's most
volatile surface).

PermissionKey is declared as `const PermissionKey =
contractactions.PermissionKey`, a direct reference rather than a
restated literal, per PRD 006's hard constraint 4: PermissionAction
sets this gin context key and GetPermissionFromContext reads it back,
and an independently declared copy could silently drift from core's if
one were ever edited without the other. permission_test.go replaces
the detailed data-permission regression suite - which now lives in
core, next to the logic itself - with a test of this package's own
wiring: that PermissionAction and both of this package's own read
paths (GetPermissionFromContext, and c.Get(actions.PermissionKey)
directly) still meet on the same key.

Counterproof performed and reverted (not part of this commit): with
PermissionKey redeclared here as the literal "dataPermission" and
core's copy changed to a different value, TestPermissionKeyMatches-
WhatPermissionActionSets went red while GetPermissionFromContext's own
round-trip stayed green - confirming the exported constant, not the
GetPermissionFromContext wrapper, is what an independent literal would
put at risk.

PRD 006 F3/F5.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:15:58 +08:00
zhangwenjian 954ebdc9eb refactor🎨: turn common/dto into a thin alias of go-admin-core
AutoForm, ObjectById/ObjectGetReq/ObjectDeleteReq, Pagination,
GeneralDelDto/GeneralGetDto and Index/Control are now type aliases of
go-admin-core's sdk/contract/dto; OrderDest, MakeCondition and
Paginate forward to the same package (functions cannot be aliased the
way types can).

MakeCondition no longer reads common/global.Driver to choose which SQL
dialect to resolve search tags against. The lowered version reads
db.Dialector.Name() from inside the closure it returns instead, which
is always the driver the caller's own *gorm.DB is bound to - correct
even with more than one database open with different drivers, which a
single process-wide variable could never be. global.Driver is marked
Deprecated accordingly; it is still set and still readable for fork
code that reads it directly.

PRD 006 F2/F5.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:15:58 +08:00
zhangwenjian 2840010dfd refactor🎨: turn common/models into a thin alias of go-admin-core
ControlBy, Model, ModelTime, ActiveRecord, BaseUser, Response, Page,
Migration and the menu type constants now read `type X = pkg.X` /
`const X = pkg.X` against go-admin-core's sdk/contract/models instead
of defining these shapes locally. Every embed, GORM tag and JSON tag
is unchanged - a type alias is the same type, not a new one - and
every existing import of go-admin/common/models keeps compiling with
no changes of its own (verified with `git diff --exit-code` over the
70 files that import common/models, common/dto or common/actions).

The menu type constants (Directory/Menu/Button) are declared as direct
references rather than restated literals: an independently written
copy of the same value can be edited out of step with go-admin-core's,
where a direct reference cannot (PRD 006 hard constraint 4).

PRD 006 F1/F5.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:15:51 +08:00
zhangwenjian b147d9b833 build🔧(deps): require go-admin-core v2.5.0
v2.5.0 carries the sdk/contract packages the commits that follow alias
common/models, common/dto and common/actions onto.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:15:33 +08:00
wenjianzhang b3ecb81614 Merge pull request #896 from go-admin-team/fix/sys-user-privesc
fix🐛: 修复 sys-user 更新接口的垂直越权
2026-09-05 00:59:59 +08:00
wenjianzhang ce4581bb99 Merge pull request #897 from go-admin-team/feat/006-security-prereq
fix🐛: 数据权限的三处静默失效
2026-09-05 00:59:12 +08:00
zhangwenjian f406ca0160 test: fail loudly instead of skipping when the sqlite setup breaks
The privilege-escalation tests skipped themselves when opening the in-memory
database or running AutoMigrate failed. Both depend on nothing outside the
process, so a failure there means the environment is genuinely broken - and a
security regression that quietly does not run is worse than one that is
missing, because CI stays green either way.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 19:10:53 +08:00
zhangwenjian 39ea1f6aef fix🐛: normalize a NULL data scope too, not just an unrecognized one
`NULL NOT IN (...)` evaluates to NULL rather than true, so the previous
condition left a NULL data_scope exactly as it found it - and NULL is the one
value that most needs the repair: it scans into a Go string as "", which is
what the fail-closed default now refuses.

The column has no NOT NULL constraint, so the value is reachable from any
writer that is not the admin UI.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 19:10:53 +08:00
zhangwenjian b2053f507a refactor🎨: drop the unused second data permission implementation
app/admin/models/datascope.go carried a second copy of the scope logic with no
callers. Its department-tree pattern was written as "%" + id + "%" instead of
"%/" + id + "/%", so dept_id 1 also matched /11/, /21/ and /100/ - visibility
into unrelated subtrees.

It sat where someone looking for a data permission example would find it. The
copy that is actually wired up stays in common/actions.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:55 +08:00
zhangwenjian 9520117914 feat: normalize invalid data scopes on existing installs
The seed fix only reaches new installations. An install that imported the old
db.sql already has an administrator with an empty data_scope, and after the
fail-closed change that account sees nothing.

The migration rewrites any value outside "1".."5" to "1", which is the
behaviour those rows had before. Plain SQL rather than the frozen migration
models, per the rule that migrations after 1786700003000 must not use them.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:55 +08:00
zhangwenjian 7a5fc7d440 fix🐛: give the seeded admin role an explicit data scope
The shipped seed data left data_scope empty for the built-in administrator.
That was harmless while an unrecognized scope meant "see everything"; with the
previous commits it means the opposite, so a fresh install with data permission
enabled would have blinded its own default account on every list endpoint.

The admin short-circuit does not help here: role_key == "admin" bypasses Casbin,
not the data permission scopes, which never look at role_key.

The value is "1" - all data - which is the behaviour the empty string used to
produce, so this restores the intent rather than tightening it. A test reads
both seed files back so the pair cannot drift apart again.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:41 +08:00
zhangwenjian bd5e83d464 fix🐛: validate data scope on the role DTOs
Nothing checked what went into sys_role.data_scope, so creating a role without
a dataScope stored an empty string - the value that used to be indistinguishable
from "see everything".

All three DTOs that write the column are validated, not just the insert path:
they target the same column, and guarding one entrance while leaving two open
would not be a guard.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:41 +08:00
zhangwenjian 63dd40a8d7 test: cover the data scope failure directions
A table over all five scopes plus the ones that are not scopes, asserting the
generated SQL rather than a boolean, because the defect was that two different
intentions produced the same query.

The rows that matter are the negative ones: an unrecognized value, a zero
value, and a department scope with a non-positive id. Each was verified to go
red with its own fix reverted and the others in place, so a regression names
the defect it belongs to.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:41 +08:00
zhangwenjian 32bd88504d fix🐛: fail closed when the department id is not positive
dept_path is built as "/0/" + id + "/..." for every department, so a DeptId of
0 turns the department-tree pattern into '%/0/%' - which matches every row in
sys_dept. The scope meant to narrow visibility to one subtree returned the
whole organisation instead.

Both department scopes now refuse a non-positive id rather than building a
pattern from it. The admin DTO validates deptId, but seed scripts, SSO and
third-party registration paths do not, and after the contract move the caller
is no longer ours to control.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:26 +08:00
zhangwenjian d70818a9db fix🐛: fail closed on an unrecognized data scope
The switch ended in `default: return db`, which is the same answer as "this
role may see everything". That made a legitimate scope indistinguishable from a
broken one: "1" (all data) had no case of its own and fell into default too, so
"1", "", "6" and a zero value all produced byte-identical SQL.

Three changes, in this order, because reversing them would break "1":

  - the five scope values become named constants, so a reader can tell which
    string means what without consulting the seed data
  - "1" gets an explicit case, which is what frees default to mean "not a
    scope I recognise"
  - default now matches nothing rather than everything

SysRole DTOs accept the scope unvalidated, so an empty string reaches this
switch from ordinary use, not just from a corrupted row.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:26 +08:00
zhangwenjian 9d4a425fc0 fix🐛: abort the request when the ORM is unavailable
PermissionAction logged the error and returned. Gin treats a plain return as
"carry on", so the request reached the business handler with PermissionKey
never set - and a zero DataPermission means Permission() adds no WHERE clause
at all. A database hiccup turned into full visibility, silently.

The neighbouring newDataPermission branch already aborts. This one now does the
same.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:12 +08:00
zhangwenjian 4d6456a588 test: cover vertical privilege escalation on sys-user update
Two directions, because the fix has to hold both: an attacker with no policy on
this route cannot raise another user's role, and a self-edit cannot raise its
own. The second one is what keeps the fix from being "just remove the route
from CasbinExclude", which would break the profile page.

The tests drive the handler directly rather than through the router, because
the middleware is exactly what does not run for this route - the defence lives
in the handler, so that is where it has to be proven.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:23:07 +08:00
zhangwenjian 07ff92aa55 fix🐛: lock privileged fields on self-edit
The profile page posts the whole user object back, including roleId, deptId and
status, because it renders from a full SysUser it fetched earlier. A caller
editing their own record can therefore hand back a tampered roleId.

Self-edits now reload those three fields from the database and ignore whatever
the request carried. For an honest client this is a no-op - the values it sends
are already its own - so the profile page keeps working unchanged.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:23:07 +08:00
zhangwenjian 4156387eb9 fix🐛: enforce Casbin when editing another user
PUT /api/v1/sys-user sits in CasbinExclude so the profile page can reach it,
which means AuthCheckRole never runs for this route. The handler took the
target user id from the request body, so any authenticated caller could edit
another user's record - including their roleId.

The route has to stay excluded: the profile page and the admin user list share
this one endpoint, so removing the exclusion would break self-service editing
for every non-admin role. The check therefore moves into the handler: when the
target is not the caller, the request is put through Casbin explicitly.

EnforceRoleFor carries the same admin short-circuit and enforcement AuthCheckRole
uses, so a route that opts out of the middleware can still ask the same question.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:23:01 +08:00
wenjianzhang 34773a0a81 Merge pull request #894 from go-admin-team/chore/core-v2.4.1
Bump go-admin-core to v2.4.1
2026-09-01 20:47:11 +08:00
zhangwenjian 36a018400b chore🔧(deps): bump go-admin-core to v2.4.1
Documentation wording only; no code change between the two.
2026-09-01 20:42:36 +08:00
wenjianzhang 7bb02c5f1d Merge pull request #893 from go-admin-team/docs/contract-wording
Describe the rules rather than who follows them
2026-09-01 20:38:34 +08:00
zhangwenjian 15fb128236 docs📝: describe the rules rather than who follows them
The warning on Authorizator matters to anyone keeping a copy of that file, not
to one particular consumer, and it reads better addressed to all of them: check
what reads those context keys before taking this change.
2026-09-01 19:56:27 +08:00
wenjianzhang 3581e060ec Merge pull request #891 from go-admin-team/feat/003-app-prep
Groundwork for installable applications
2026-09-01 19:38:23 +08:00
zhangwenjian 0604a29596 feat(server): run the startup hooks through core
The package-level AppRouters slice keeps working and keeps running first, so
a fork that only ever appended to it sees no change. What is new is that the
core registry runs too, and that before callbacks run at all - this server
never had a loop for them.

Both go through core RunAppRouters / RunBefore, which brings the panic guard
and the seal with them.
2026-09-01 18:18:38 +08:00
zhangwenjian d8a2958797 chore🔧(deps): bump go-admin-core to v2.4.0 2026-09-01 18:18:38 +08:00
zhangwenjian ab28fa7bed docs📝: write down what a third-party app may depend on 2026-09-01 17:45:41 +08:00
zhangwenjian e88d751039 chore🔧: run the silent-failure checks in CI 2026-09-01 17:45:41 +08:00
zhangwenjian b836945eea feat: add checksilent, for the failures that do not report themselves
Six checks, five at ERROR and one - the cross-repository menu-name comparison -
at WARN, because it can only match by regular expression across two modules and
a false positive that fails CI teaches people to silence the tool.

The summary names which contract roots were actually scanned: core/ is a
separate module with no directory here, and a check that quietly covers less
than it claims is worse than no check.
2026-09-01 17:45:41 +08:00
zhangwenjian d7a8e66753 feat: add migrate status, --dry-run and --app
--app rejects a code nothing was registered under, on all three paths. It used
to take a typo as "nothing matched" and report success: migrate said the app
was unknown and still exited 0, while --dry-run and status printed the same
words an up-to-date database produces.
2026-09-01 17:45:40 +08:00
zhangwenjian 68780a845c feat: register migrations under an app code with ForApp 2026-09-01 17:45:40 +08:00
zhangwenjian 487dc94a2e feat: record on sys_migration which app a migration belongs to 2026-09-01 17:45:40 +08:00
zhangwenjian 016e977776 refactor🎨: drop Authorizator assertions that never matched
The map Authorizator receives is built by IdentityHandler in the same file and
carries IdentityKey / UserName / RoleKey / UserId / RoleIds / DataScope - not
user and role. Both assertions failed on every request, and because the ok
result was discarded the five c.Set calls stored zero values and the function
returned true anyway. Nothing in this repository or in core reads those keys.

go-admin-pro has its own copy of this file and does read them; this change
must not be carried over there verbatim.
2026-09-01 17:45:40 +08:00
zhangwenjian dcfe512204 refactor🎨: move the operation log status constants out of app/admin
common/middleware imported app/admin/service/dto for two string constants,
which made a package apps are told to build on depend on one particular app.
2026-09-01 17:45:40 +08:00
zhangwenjian fe6ebfd47c chore🔧: ignore the Go workspace files and the checksilent binary
go.work points this module at a local checkout of go-admin-core while the
two are developed together. It is a local tool and must never be committed:
CI resolves core from go.mod.

checksilent is where `go build ./tools/checksilent` drops its binary - four
megabytes beside the server one, which was already ignored by name.
2026-09-01 17:45:40 +08:00
wenjianzhang eba5fba3da Merge pull request #889 from go-admin-team/fix/password-hook-and-body-buffering
fix: a password hook that could destroy credentials, and a body copy on every request
2026-09-01 14:22:13 +08:00
zhangwenjian b7e9a79225 fix🐛: refuse an out-of-scope API update with the permission message
The previous commit added a data-permission scope to SysApi.Update and
returned early on db.Error, which left the RowsAffected check below it
unreachable: First reports a row the scope excluded as ErrRecordNotFound,
so the caller got "record not found" where the code meant to say
"无权更新该数据".

Map that one error to the permission message and drop the check it made
dead. The two cases - the row does not exist, and the row exists but is
not yours - have to look the same from outside, and now do.

Found by Copilot's review of #889.
2026-09-01 14:17:20 +08:00
wenjianzhang deffb19fd8 Merge pull request #888 from go-admin-team/ci/run-tests
Run the test suite in CI
2026-09-01 11:38:10 +08:00
zhangwenjian c858b322bd fix🐛: apply the data permission when updating an API
SysApi.Update took a DataPermission and never used it, so with data
permission enabled the update reached rows the caller could not read
through GetPage, Get or Remove, which all scope the query. It also
reported "无权更新该数据" for a row that simply did not exist, a message
that only becomes true once the scope is applied.

Drops the Debug() left on the query, which logged the statement for
every call.
2026-09-01 11:35:51 +08:00
zhangwenjian 1b5b52f0f1 perf👌: only read the request body when the operation log will store it
LoggerToFile is registered on the engine, so every POST, PUT, GET and
DELETE had its body copied into memory - through a bytes.Buffer, a
ReadAll and a string conversion - before any handler ran. The only
consumer is operParam on the operation-log row, which is written when
logger.enableddb is on, and that is off in the shipped configuration.

There was no size limit either, and a file upload is a POST like any
other: a 1MB request allocated 4.3MB here and a 16MB upload allocated
about 67MB, to build a value nobody stored.

The body is now read only when the operation log will use it, and at
most 32KB of it. The handler still receives the whole request: it reads
the copied part from memory and the rest from the connection, so what
this holds is bounded however large the request is. 32KB also keeps the
value inside the TEXT column it is written to.

The bufio.Writer this replaces was never flushed. Nothing was truncated
only because bytes.Buffer implements io.ReaderFrom, so io.Copy bypassed
the buffer entirely - a different destination would have dropped the
tail of every request body.
2026-09-01 11:35:45 +08:00
zhangwenjian ecb31a158b fix🐛: stop re-hashing a password that is already hashed
BeforeCreate and BeforeUpdate run Encrypt on whatever is in the struct,
and a user read from the database carries the stored hash in Password.
Hashing it again produces a hash of a hash: the password that user knows
stops matching, they cannot log in, and nothing reports an error.

Only the Omit("password") on SysUser.Update stood between that and the
stored credential. Any other write to this model - a profile update
written the way every other model here is written - destroys the
password, permanently and silently.

Encrypt now returns early when Password already parses as a bcrypt hash.
That also removes the round SysUser.Update was paying and discarding:
306ns where it was 54.7ms, on a route reachable without the permission
check, since PUT /api/v1/sys-user is in CasbinExclude.

The cost of deciding from the value is that a password which is itself a
well-formed bcrypt hash would be stored unchanged. That is a
60-character string beginning "$2a$", and it grants whoever set it no
access they did not already have.
2026-09-01 11:35:33 +08:00
zhangwenjian 1aecc140dc ci🔧: run the test suite on every push and pull request
The repository has 19 test files and nothing was running any of them. Both
workflows build with go build, which does not compile _test.go, the Makefile's
test target was commented out, and there is no pre-commit hook. Every test in
the tree, including the schema guards that exist precisely to catch a silent
breakage, only ran when someone remembered to type go test.

Enables the commented-out target and calls it from go.yml, the one workflow
that fires on every push and pull request. build.yml is left alone: it skips
documentation-only changes and deploys on master, so it is the wrong place for
a gate that should never be skipped.

Runs with -race. common/actions reuses model instances across concurrent
requests, so a Generate() that returns in place rather than a copy leaks data
between them, which a single-threaded run cannot see.

Verified locally: the suite passes under CGO_ENABLED=0 and under -race, and
make test exits non-zero when a test fails, so the step actually gates.

Claude-Session: https://claude.ai/code/session_01DJhM6LvhkNPej35wy9F7Aq
2026-09-01 11:33:08 +08:00
wenjianzhang e464a4aedd Merge pull request #887 from go-admin-team/fix/migration-model-soft-delete-drift
Guard new migrations against the frozen seed models
2026-08-31 15:06:05 +08:00
wenjianzhang 595c4a6be5 Merge pull request #885 from go-admin-team/fix/casbin-tenant-and-pattern-cache
fix: key the casbin enforcer by tenant, and stop recompiling patterns in the exclusion scan
2026-08-31 15:01:42 +08:00
zhangwenjian d115c5299c docs📝: say which models package a new migration may seed through
The hazard had no signal at its point of contact. Someone adding a business
module is told to copy 1786700001000_demo_menu.go, which imports the frozen
seed models - correct for that file, wrong for anything ordered after the
soft-delete conversion. The frozen ModelTime carried no comment at all, so
opening it taught the reader nothing.

Documents the boundary in three places the author actually passes through:
the frozen type itself, the contributor guide, and the module-scaffolding
skill, which previously said to copy the reference file verbatim and now
says to copy its structure but not its imports.

Claude-Session: https://claude.ai/code/session_01DJhM6LvhkNPej35wy9F7Aq
2026-08-31 15:01:26 +08:00
zhangwenjian 9bd542bb59 test: guard post-conversion migrations against the frozen seed models
Migrations ordered after 1786700003000 must not seed through
cmd/migrate/migration/models. That package's ModelTime declares a nullable
gorm.DeletedAt, which is the shape the columns had until that migration
converted deleted_at to a NOT NULL millisecond marker.

Afterwards it breaks in both directions. Writes put NULL into a NOT NULL
column and fail on the first insert. Reads are scoped "WHERE deleted_at IS
NULL" while live rows hold 0, so they match nothing - and 1786700001000
looks the admin role up that way and treats ErrRecordNotFound as "roles are
not seeded yet, skip authorisation", which would leave a module seeded with
no permissions and the migration still recorded as applied.

A fresh database does not surface either one: every migration using that
package today is ordered before the conversion, so it runs while the column
is still nullable. Only a migration added afterwards hits it.

Also pulls the import scan out of importsRuntimeModels so both checks share
one implementation, and derives the version through migration.GetFilename
rather than a second filename-parsing rule.

Verified by adding a violating migration and confirming the test fails with
an actionable message, then removing it and confirming the suite passes.

Claude-Session: https://claude.ai/code/session_01DJhM6LvhkNPej35wy9F7Aq
2026-08-31 15:01:11 +08:00
zhangwenjian 0fa015b6d0 perf👌: stop recompiling patterns when scanning the casbin exclusion list
AuthCheckRole walks CasbinExclude for every non-admin request, and used
casbin's util.KeyMatch2 to test each entry. That delegates to
util.RegexMatch, which is regexp.MatchString - it compiles its pattern on
every call - so a 32-entry list cost about 2,566 allocations per request
before the request reached Enforce.

Test the method first, which rules out most entries with a string
compare, and take the path test from go-admin-core, whose KeyMatch2
answers the same thing without recompiling. The scan drops to 52ns and no
allocations.

The loop moves out of AuthCheckRole so the tests exercise the code a
request runs rather than a copy of it, and an allocation budget fails if
the uncached matcher comes back.
2026-08-31 14:01:32 +08:00
zhangwenjian ec7d838ebd fix🐛: key the casbin enforcer by tenant host
setupSimpleDatabase runs once per configured database - one per host in
the multi-tenant configuration - and passed the same empty key to
mycasbin.Setup every time. Setup caches per key, so every host after the
first was handed the enforcer built from the first host's database and
was authorized against a casbin_rule table that was not its own.

Takes effect with the go-admin-core release that keys the cache; before
it, Setup ignored the argument entirely.
2026-08-30 10:03:26 +08:00
wenjianzhang 26e116c16c Merge pull request #884 from go-admin-team/ci/skip-deploy-for-docs
ci🔧: skip the deploy workflow for documentation-only changes
2026-08-29 14:49:39 +08:00
zhangwenjian 90d98893f5 ci🔧: skip the deploy workflow for documentation-only changes
A push to master here does not just build: it pushes an image, runs the
migrations and restarts the demo container, so the site takes a short outage
each time. The last two merges were markdown only and both paid for it.

Beyond the waste, a deploy can fail for reasons unrelated to the change that
triggered it - a container that will not come up, a database that is briefly
unreachable - and a README edit should not be able to turn the demo red.

Only build.yml is filtered. go.yml still builds on every push and pull request,
so nothing loses its compile check, and the badge keeps reporting the same
workflow it reports today.
2026-08-29 14:29:05 +08:00
wenjianzhang 10f162bf5d Merge pull request #883 from go-admin-team/fix/readme-vitepress-syntax
docs📝: drop VitePress container syntax GitHub cannot render
2026-08-29 14:28:11 +08:00
zhangwenjian 19909746f5 docs📝: drop VitePress container syntax GitHub cannot render
`:::tip` and its closing `:::` are VitePress custom containers. GitHub has no
such syntax, so both markers rendered as literal text: a paragraph beginning
":::tip" and a stray ":::" sitting alone above the next heading.

The Chinese README carries the same warning as a plain paragraph, which GitHub
renders correctly, so the English one now matches it. Verified through GitHub's
own markdown API: the literal marker no longer appears in the output and the
warning survives as ordinary text.

Only README.md was affected; the other three never had it.
2026-08-28 20:36:24 +08:00
wenjianzhang 205febdb8a Merge pull request #882 from go-admin-team/docs/readme-links-and-languages
docs📝: fix the badges and links, add Traditional Chinese and Japanese READMEs
2026-08-28 20:32:13 +08:00
zhangwenjian 5aec4ba32b docs📝: add Traditional Chinese and Japanese READMEs
The project had English and Simplified Chinese. These two follow the same
structure - same sections, same code blocks, same contributor list - so a
reader in any of the four sees the same document.

The Traditional Chinese is a translation rather than a character conversion of
the Simplified: the terminology differs (設定檔, 資料庫, 選單, 程式碼產生,
排程任務, 相依套件), and a converted file would read as machine output to
anyone who actually uses it.

Language navigation across all four is unified in the same commit, since a link
to a file that does not exist yet would be worse than no link.
2026-08-28 20:19:50 +08:00
zhangwenjian 8f1ea50dfe docs📝: point the badges and documentation links at the right places
The build badge rendered "build - failing" on both READMEs while CI was green.
It referenced the workflow under the old personal repository path, where the
status has been stale for years - so the first thing anyone saw on opening the
project was a failed build. It now points at the current repository, names the
workflow file explicitly, and pins the branch, so it reports master rather than
whatever happens to be the default branch later.

The workflow it reports on is go.yml, which is what the old badge referenced
by workflow name and is the right one to show: build.yml also deploys the demo
site, so a server-side problem there would turn the badge red while the code
is fine.

The licence badge read from mashape/apistatus, the example repository from
shields.io's own documentation. It happened to show MIT, the same licence this
project uses, so nobody noticed - but it reports someone else's licence.

Documentation links were spread across three hosts: doc.go-admin.dev redirects
to www.go-admin.pro, www.go-admin.dev serves byte-identical content, and only
the Chinese README linked the canonical host at all. All of them now point at
www.go-admin.pro directly rather than relying on a redirect outliving the
domain that issues it.

Two smaller ones: the gorm link pointed at the archived v1 repository while the
project builds on gorm.io v2, and the English introduction listed two UI kits
where the Chinese listed three, with an Ant Design demo linked directly below.
2026-08-28 20:19:50 +08:00
wenjianzhang 1483ca401d Merge pull request #881 from go-admin-team/fix/production-defaults
fix🐛: the settings a deployment needs, and the ones that were leaking
2026-08-28 19:50:18 +08:00
zhangwenjian ed74623a73 test: add an end-to-end load test harness
Skipped unless GOADMIN_BENCH_ADDR points at a running server, so `go test
./...` is unaffected.

Reports latency percentiles rather than an average, which is what capacity
planning needs, and a status-code distribution - that last part is how the rate
limiter's 200-on-rejection was found, since throughput alone looked excellent
while nothing reached a handler.

Includes a routing-floor control case. When a business endpoint matches it, the
measurement has stopped describing the endpoint and started describing the
transport, or the load generator when both share a machine.
2026-08-28 19:42:38 +08:00
zhangwenjian 1bc2e22833 fix🐛: give the config templates the defaults a deployment actually needs
Two settings that decide whether a deployment survives load, neither of which
appeared in any template.

The connection pool. Left unset, Go's defaults apply, and MaxIdleConns is 2:
under load almost every request opens a TCP connection and closes it again,
local ports run out, and the process answers "can't assign requested address"
to everything. Not slower - unavailable. A sweep against MySQL collapsed to
zero successful responses at 64 concurrent requests without these, and served
13,846 req/s with no errors once they were set.

The queue buffer. poolSize is the point at which messages start being dropped,
not a tuning knob: a full queue discards the message and returns an error
rather than blocking, and each stream has one consumer goroutine writing to the
database. At the previous default of 100 a load test lost over 60% of them; at
1000, none. Login and operation logs travel this queue, so what gets lost is
audit data - though only when logger.enableddb is on.

Both carry the reasoning in the file, because the failure mode of each is
invisible until it happens in production.
2026-08-28 19:42:38 +08:00
zhangwenjian cd8edfa5d4 fix🐛: reject rate-limited requests with 429 and make the threshold configurable
A rejected request answered 200 with the failure only in the body, so every
layer that reads the status line counted it as served: load balancers, metrics,
client-side retry. A load test against this reported the limiter's own
rejections as successful traffic and overstated throughput more than tenfold.

The threshold was a constant in the middleware, which made 200 QPS the ceiling
of every deployment with nothing in the configuration to reveal it. It now
reads extend.rateLimit.inboundQPS; an absent value keeps 200, so an upgrade
changes nothing, and zero disables the limiter for a deployment behind its own
gateway.

Also drops Strategy: system.BBR. Reading sentinel's source, the adaptive
strategy is consulted only for Load and CpuUsage - for InboundQPS the trigger
count is compared directly - so it read as if the limit adapted to the machine
when it never did.
2026-08-28 19:42:21 +08:00
zhangwenjian dcc2c8e175 fix🔒: stop logging the captcha answer
The answer was written at info level on every captcha request, so a currently
valid answer sat in the application log. Anyone able to read the log - an
operator, a log aggregator, anything that ships logs off the host - could
bypass the check the captcha exists to enforce.

The default log level records it, so this was not limited to debug builds.
2026-08-28 19:42:21 +08:00
zhangwenjian d991a285ba chore🔧: upgrade go-admin-core to v2.2.0
Carries four concurrency fixes and a bounded in-memory cache. The two that
reach this repository are the search resolver, which no longer panics on an
unexported field in a DTO and skips tag parsing for zero-valued ones, and the
captcha driver, which is built once rather than per request.

The cache bound does not apply here: config.CacheConfig.Setup() returns the
older Memory implementation, which core leaves unbounded.
2026-08-28 19:42:19 +08:00
wenjianzhang 76c9d1211e Merge pull request #880 from go-admin-team/fix/dsn-in-logs
ci🔧: migrate before deploying, roll back on failure — and stop logging the database password
2026-08-27 17:31:52 +08:00
zhangwenjian f5273f5a58 ci🔧: migrate before deploying, and roll back when the new version does not come up
Closes #871.

The deploy did docker rm -f then docker run. Nothing ran migrations, so
new code met old tables, and nothing checked the result - a container
that exits immediately left the site down with a green deploy.

Now, in order: pull the image, run the migration with it, and only then
touch what is running. A failed migration stops there, leaving old code
with the old schema, which is at least self-consistent.

The running container is renamed rather than removed, so it can be
started again unchanged if the new one does not become healthy. Healthy
means both an HTTP response and a database connection in the log: the
captcha endpoint answers without touching the database, so it alone
would call a container healthy that cannot reach MySQL.
2026-08-27 16:19:03 +08:00
zhangwenjian 54ffaac9c5 chore🔧: say which migration is running, not a column of ones
An applied migration printed its count - a bare '1' - so a database with
seven of them wrote seven lines of '1' at every start, and a failure said
only which error, never which migration.

It now names each one as it applies, reports the total, and says so when
there is nothing to do.
2026-08-27 16:19:03 +08:00
zhangwenjian 484de2e698 fix🔒: stop writing the database password into the log
The startup line printed the DSN whole:

  * => goadmin:<password>@tcp(host:3306)/go-admin?...

So every deployment wrote its own database credential into its own logs,
where a log shipper, a support bundle or a screenshot of a terminal
carries it onward. Found while reading deploy output, which is exactly
how it leaks.

The host and username stay - they are what makes the line worth printing
- and only the password is replaced. Both DSN shapes this project accepts
are covered, a sqlite path is left alone, and anything unparseable is
withheld rather than echoed, since it may hold a credential too.
2026-08-27 16:17:12 +08:00
wenjianzhang d72ff76aad Merge pull request #879 from go-admin-team/ci/config-path-secret
ci🔧: keep the host config path out of a public repository
2026-08-27 15:41:54 +08:00
zhangwenjian 4a523bed92 ci🔧: keep the host config path out of a public repository
The path is not a credential, and the file it points at is 600 and owned
by root, so this is not what protects it. But the repository is public
and there is no reason to publish the server's directory layout next to
the deploy that uses it.

DEMO_CONFIG_PATH holds it instead. It has to be set before this merges,
or the deploy stops at the guard - which is the intended failure: better
that than falling back to the sqlite in the image.
2026-08-27 15:37:09 +08:00
wenjianzhang 55dc33b865 Merge pull request #878 from go-admin-team/ci/demo-on-mysql
ci🔧: run the demo on the managed database instead of a bundled sqlite file
2026-08-27 15:33:15 +08:00
zhangwenjian 3d13f5856a ci🔧: point the demo at the managed database
The demo ran on the sqlite file baked into the image, so every deploy
reset it and nothing there resembled how anyone actually runs this.

The config is mounted from the host rather than taken from the image.
config/settings.demo.yml ships in a public repository and is copied into
a public image, so the connection string cannot live there; that copy
stays on sqlite, which is what a fresh clone should get.

The deploy refuses to start if the host config is missing, rather than
falling back to the image's sqlite and looking like it worked.
2026-08-27 12:48:32 +08:00
wenjianzhang aa2976ba17 Merge pull request #877 from go-admin-team/fix/mysql-fresh-install
fix🐛: MySQL installs could not log in — the migration run stopped at a tinyint overflow
2026-08-27 12:18:04 +08:00
zhangwenjian 8e141ff8a0 fix🐛: the code generator listed no tables at all
sys_columns and sys_tables were left out of the soft-delete conversion in
1786700003000. Their runtime models embed common.ModelTime, which is the
millisecond marker, so GORM queries them with deleted_at = 0 - against a
nullable datetime column holding NULL. Every row was invisible.

The repository carries two ModelTime types: the one under
cmd/migrate/migration/models still has a nullable gorm.DeletedAt and is
what builds the tables, while common/models has the marker and is what
queries them. Nothing connected the two, so a table could be built one
way and read the other with no signal at all.

The test now walks app/ for models embedding the marker and requires a
migration to cover each. tb_demo is exempt and says why: nothing reads it
at runtime.
2026-08-27 12:12:13 +08:00
zhangwenjian 2628ab8e3e fix🐛: a seeded menu overflowed its column and stopped the migration run
sort is gorm:"size:4", which MySQL builds as a tinyint holding -128..127.
The demo menu seeded Sort: 900, so on MySQL the run stopped at
1786700001000 with Error 1264, and every migration after it - including
the soft-delete conversion - never ran.

deleted_at therefore stayed NULL while the code queries deleted_at = 0,
and the login returned 'incorrect Username or Password' on a database
whose password hash was correct all along.

sqlite ignores the declared width, so a fresh install there passed and
the fault only appeared on MySQL.
2026-08-27 12:12:00 +08:00
wenjianzhang 1b7dcd843c Merge pull request #876 from go-admin-team/perf/data-permission
perf👌: the data-permission lookup ran on every request, including when it was switched off
2026-08-24 16:11:47 +08:00
zhangwenjian f0d91fb763 perf👌: read the data scope from the token instead of joining for it
The scope is decided by the user id, the role id, the department and the
data_scope string. Three of the four were already in the token; deptid
was not, though core's user.GetDeptId has always read that claim. Adding
it removes a sys_user join from every list, detail, update and delete.

This goes no more stale than rolekey does, which Casbin has read from
the token since the beginning: both settle on the next login.

A token minted before this still works. Its claims are incomplete, and
the lookup runs for it as before.
2026-08-24 15:36:11 +08:00
zhangwenjian 7238c6a26d perf👌: stop looking up a data scope that is switched off
Permission() returns the query untouched when EnableDP is false, so the
lookup feeding it has nothing to feed. The lookup ran anyway: a sys_user
join against sys_role on every list, detail, update and delete, with the
result discarded.

enabledp is false in settings.full.yml, so this was the default.
2026-08-24 15:35:33 +08:00
wenjianzhang 0964cf98e2 Merge pull request #875 from go-admin-team/fix/file-store-nil-client
fix🐛: the upload endpoint panicked on source=2, and cloud storage was never wired up
2026-08-24 15:00:31 +08:00
zhangwenjian 04c6a081ae fix🐛: source=3 uploaded to aliyun, and neither provider was ever configured
thirdUpload dispatched on the source parameter and then built the same
zero-value ALiYunOSS in both branches, so source=3 could not have
reached qiniu even with credentials.

Neither branch had credentials to use. OXS.Setup is the initialisation
path and nothing in the repository called it, and no configuration field
existed to fill. The store is now taken from extend.fileStore, and a
provider that was not configured says so rather than producing the
provider's own complaint about an empty bucket name.

The two handlers passed errors.New("") to e.Error, discarding what
actually went wrong; they now pass the error.
2026-08-24 13:25:54 +08:00
zhangwenjian fcbd9ae02e fix🐛: an unconfigured object store reports it instead of panicking
Each implementation keeps its provider client in an interface{} field that
Setup assigns, so an unconfigured store holds nil - and asserting nil to
the provider's client type panics:

  panic: interface conversion: interface {} is nil, not *oss.Client

The upload endpoint reaches that path for any request naming a provider
the deployment never configured.

Three more things were wrong in the same files. OXS.Setup printed a
failure and returned the store anyway, handing back exactly the broken
object that panics. HuaWeiOBS.UpLoad printed the provider's error and
returned nil, so a failed upload reported success. Both it and
QiNiuKODO.UpLoad asserted the local path was a string without checking.

The tests asked the reader to paste their own credentials, so they failed
for everyone who did not. They now cover the guards and skip the part
that needs a provider unless credentials are in the environment.
2026-08-24 13:23:07 +08:00
wenjianzhang d34d30a197 Merge pull request #874 from go-admin-team/ci/serialize-deploys
ci🔧: run one deploy at a time
2026-08-23 14:15:21 +08:00
zhangwenjian ecfea845c2 ci🔧: run one deploy at a time
Two merges seconds apart raced. Both runs do docker rm -f then docker
run; the second removed the container the first had just created, and
the first's docker run failed on the name conflict:

  Conflict. The container name "/go-admin-api" is already in use

The deploy went red and the demo stayed on the older image, which is the
worse half: a failure that leaves the wrong version running.

Grouping by ref serialises pushes to master while leaving pull request
runs independent, since those carry their own ref.
2026-08-23 14:11:49 +08:00
wenjianzhang e05ff7c809 Merge pull request #873 from go-admin-team/chore/drop-dockerfilebak
chore🔧: delete Dockerfilebak
2026-08-23 14:03:34 +08:00
wenjianzhang 28a9626661 Merge pull request #872 from go-admin-team/chore/skill-new-business-module
docs📝: add the new-business-module skill, and keep the rest of .claude out
2026-08-23 14:03:28 +08:00
zhangwenjian 96b2cb3acf chore🔧: delete Dockerfilebak
Added in 2022 and never touched since. Nothing references it - not the
workflows, not the Makefile, not a script - and it could not build
anyway: it copies config/settings.yml out of the builder, and that file
is gitignored.

It is a leftover from when the image was built inside the container,
before that was replaced by copying a binary built on the runner. Its
MAINTAINER line was the last one in the repository; Docker deprecated
the instruction in favour of LABEL maintainer years ago.
2026-08-23 13:43:40 +08:00
wenjianzhang 87fe6b7d9b Merge pull request #864 from go-admin-team/chore/core-v2
chore🔧: move to go-admin-core v2
2026-08-23 13:43:19 +08:00
zhangwenjian 722de8ea65 chore🔧: move the generator templates to v2 as well
The code generator writes Go files, and its templates still spelled the
old import paths, so a module generated after this migration did not
compile: the router it emits declares InitBusinessRouter with the v1
*GinJWTMiddleware while common.AuthInit now returns the v2 type.

Two of the paths moved rather than gaining a /v2 segment - the jwtauth
and response shims under sdk/pkg are gone in v2 - so this is not the
same rewrite the Go files got.
2026-08-23 13:28:17 +08:00
zhangwenjian 8ffde94433 chore🔧: move to go-admin-core v2
Every import of the module changes, not only the seven packages that
moved out of sdk/pkg: Go requires the major version in the path from v2
on. Both happen in one pass —

    go run github.com/go-admin-team/go-admin-core/tools/coreupgrade@v2.0.0 -w -v2 .
    go mod tidy

— which is the command the release notes give, run here as a consumer
would run it. 210 imports across 95 files.

The compatibility shims this used are gone in v2, so the paths that
moved had to move: sdk/pkg/captcha, sdk/pkg/jwtauth and its user
package, sdk/pkg/response and sdk/pkg/casbin.

The count of unformatted files is unchanged at 34, none of them touched
by this: the tool reformats a file only if it was already gofmt clean,
so a migration cannot disappear into whitespace.
2026-08-23 13:26:46 +08:00
wenjianzhang 08eef12bca Merge pull request #865 from go-admin-team/fix/codegen-inverted-guards
fix🐛: the code generator's guards were all written backwards
2026-08-23 13:24:50 +08:00
zhangwenjian ab0e8e6056 docs📝: add the new-business-module skill, and keep the rest of .claude out
The skill walks a single-table CRUD module end to end: migration, the
Actions-mode model, dto and router, and the sys_menu / sys_api /
casbin_rule seed data without which the module builds but never appears.

.claude was ignored wholesale. Un-ignoring the skills directory would
have committed every skill put there, including personal ones, so the
skills that ship are re-included one directory at a time.

AGENTS.md now points at 1786700001000_demo_menu.go for the seed data,
which is the runnable version of what the skill describes.
2026-08-23 13:20:26 +08:00
zhangwenjian 2cef52d906 chore🔧: record the modules the code imports as direct
go mod tidy moves glebarez/sqlite and gorm.io/plugin/soft_delete out of
the indirect block: the tests import the first and common/models the
second. CI runs tidy before building, so the tree was dirty from the
first command.
2026-08-23 13:19:15 +08:00
zhangwenjian 95077b116b fix🐛: the candidate table query assumed one database
The exclusion list was a subquery against `$GenConfig.DBName`.sys_tables,
so it named the schema by hand. Generating from a schema that is not the
one holding sys_tables made the whole query fail, and because the
subquery read the table directly it also counted soft-deleted entries:
deleting a generator entry never handed its table back.

Read the registrations through the model on this connection instead. An
empty list skips the clause - NOT IN (NULL) is unknown for every row,
which would leave a fresh install with nothing to generate from.
2026-08-23 13:19:15 +08:00
zhangwenjian d4cf11d313 fix🐛: report an unknown database driver instead of panicking
opens is a map, so opens[c.Driver] on a driver this build does not carry
returns a nil function, and gorm.Open calls it. The operator saw a nil
dereference inside gorm with nothing naming the driver.

sqlite3 is the case that bites: it needs cgo and is only compiled in
under the sqlite3 build tag, so the same config file works on one binary
and dies on another. Resolve the driver first and say which ones this
build supports.
2026-08-23 13:19:15 +08:00
zhangwenjian f201792d8f fix🐛: the mysql-only guard never fired
pkg.Assert panics when its condition is false, so pkg.Assert(true,
"目前只支持mysql数据库") is a no-op. On postgres or sqlserver the code
generator did not report that it needs MySQL: DBTables returned an empty
list with a nil error, and DBColumns ran its query on the zero-value
*gorm.DB left over from the branch that never assigned, which is a nil
dereference rather than a message.

Assert the driver up front instead of asserting a constant in an else,
which also removes the placeholder *gorm.DB the fall-through relied on.
DBColumns.GetPage had no guard at all and gets the same one.
2026-08-23 13:19:15 +08:00
zhangwenjian d16f5e7180 fix🐛: db columns endpoint rejected exactly the valid requests
pkg.Assert panics when its condition is false, so
Assert(TableName == "", "table name cannot be empty") rejected every
request that carried a table name and let the empty one through. The
model layer repeated the inversion with if TableName != "" { return
error }, so either one alone was enough to break the endpoint.

Flip both, and hoist the model guard out of the mysql branch so it
matches GetList ten lines below, which had it right all along.
2026-08-23 13:19:15 +08:00
wenjianzhang f8f697af2b Merge pull request #868 from go-admin-team/ci/stop-gitee-mirror
ci🔧: stop mirroring the repository
2026-08-23 13:18:19 +08:00
wenjianzhang eb8da38a46 Merge pull request #870 from go-admin-team/fix/demo-db-soft-delete
fix🐛: the soft-delete migration could not run, and the demo database never got it
2026-08-23 13:01:17 +08:00
zhangwenjian f19568c69a chore🔧: bring the bundled demo database up to the current migrations
go-admin-db.db ships in the repository and the Dockerfile copies it into
the image, which then runs only the server. Its last recorded migration
was from 2022, so every row still carried a null deleted_at while the
code queries deleted_at = 0. Nothing matched: not the login, not the
sixty-seven menus, not the five departments.

Anyone starting from the bundled sqlite database met the same wall, and
the failure reads as an incorrect username or password.
2026-08-23 12:57:05 +08:00
zhangwenjian 91bf25e5fe fix🐛: the soft-delete migration could not run against the real schema
Two assumptions held on the test's table and on nothing else.

It dropped deleted_at while an index still referred to it. MySQL and
PostgreSQL drop dependent indexes along with the column; SQLite refuses,
and the migration stopped at the first table with such an index - which
is all thirteen of them.

It also read the rows through a column named id. sys_dept keys on
dept_id, sys_user on user_id, and only some tables on id, so the pass
that carries the deletion timestamps across never ran.

The test's table had an id key and no index on deleted_at, which is
exactly the shape that lets both through. It now matches sys_user.
2026-08-23 12:56:50 +08:00
wenjianzhang 85666f160f Merge pull request #867 from go-admin-team/docs/readme-refresh
docs📝: repoint the README links that stopped resolving
2026-08-22 23:12:49 +08:00