A lightweight ASP.NET Core MVC web application for storing and sharing secrets (passwords, tokens, API keys, etc.) within a small team. All data is persisted in plain JSON files — no external database required.
| Component | Details |
|---|---|
| Framework | ASP.NET Core MVC (.NET 10) |
| Language | C# |
| Storage | JSON files in App_Data/ |
| Authentication | Server-side sessions |
| Serialization | System.Text.Json |
dotnet runThe app starts on the configured port (see Properties/launchSettings.json). On first run it automatically creates seed data (see Seed Data below).
All state lives in four JSON files inside the App_Data/ directory. The directory is created automatically if it does not exist. Files are written with indented JSON for readability. All reads are case-insensitive so hand-edited files are tolerated.
Stores user accounts.
[
{
"Username": "admin",
"Password": "admin123",
"IsAdmin": true
},
{
"Username": "alice",
"Password": "secret",
"IsAdmin": false
}
]| Field | Type | Description |
|---|---|---|
Username |
string | Login name. Case-sensitive for login, case-insensitive for access checks. |
Password |
string | Stored in plain text. Change passwords before any production use. |
IsAdmin |
bool | When true the user can read all entries regardless of per-entry restrictions. |
Security note: There is no user registration UI. Add users by editing
users.jsondirectly while the app is stopped, or by modifying the seed logic inJsonDataStore.EnsureSeedData.
Stores the secrets (called entries).
[
{
"Id": 1,
"Title": "Production DB",
"Details": "host=db.example.com\nuser=app\npassword=hunter2",
"Users": "alice, bob",
"CreatedBy": "admin",
"History": [
{
"ChangedAtUtc": "2026-03-08T10:00:00Z",
"ChangedBy": "admin",
"Title": "Production DB",
"Details": "host=db.example.com\nuser=app\npassword=hunter2",
"Users": "alice, bob"
}
]
}
]| Field | Type | Description |
|---|---|---|
Id |
int | Auto-incrementing identifier. |
Title |
string | Human-readable name shown in the entry list. |
Details |
string | The secret content — free-form text (passwords, keys, notes, etc.). |
Users |
string? | Access control list — see Per-Entry Access Control. null/empty means private; "*" means everyone. |
CreatedBy |
string | Username of the entry owner. Set automatically at creation time. |
History |
array | Full audit trail of every version — see Change History. |
An append-only audit log. Every significant event is recorded with a UTC timestamp.
[
{
"TimestampUtc": "2026-03-08T09:55:00Z",
"User": "admin",
"Action": "LoginSuccess",
"EntryId": null,
"EntryTitle": null,
"Notes": null
},
{
"TimestampUtc": "2026-03-08T10:01:00Z",
"User": "alice",
"Action": "OpenEntry",
"EntryId": 1,
"EntryTitle": "Production DB",
"Notes": null
}
]Recorded actions:
| Action | Trigger |
|---|---|
LoginSuccess |
Successful login |
CreateEntry |
New entry saved |
UpdateEntry |
Existing entry edited and saved |
OpenEntry |
Entry details page viewed by a user who has read access |
OpenEntry is only logged when the viewing user actually has access; restricted views do not produce a log entry.
Pending access requests from users who do not have permission to read an entry.
[
{
"Id": 1,
"RequestedBy": "charlie",
"EntryId": 1,
"RequestedAtUtc": "2026-03-08T11:30:00Z"
}
]| Field | Type | Description |
|---|---|---|
Id |
int | Auto-incrementing identifier. |
RequestedBy |
string | Username of the user requesting access. |
EntryId |
int | The entry they want to access. |
RequestedAtUtc |
DateTime | When the request was submitted (UTC). |
Requests are removed when approved or declined. There can be at most one pending request per user per entry.
Authentication uses ASP.NET Core's server-side session middleware.
- Sessions expire after 30 minutes of inactivity (configurable in
Program.cs). - The session cookie is
HttpOnlyand marked as essential. - On login, the username is stored in the session under the key
"username". - On logout, the entire session is cleared.
- Every controller action checks for a valid session before serving content; unauthenticated requests are redirected to the login page.
.jsonfiles underApp_Data/are blocked at the middleware level — direct URL access to any.jsonfile returns404 Not Found.
- Can view all entry titles in the list.
- Can read entry contents only for entries they own, entries where the
Usersfield includes them, or entries open to everyone. - Can create new entries (they become the owner).
- Can edit entries they are allowed to read.
- Can request access to entries they cannot read.
- Sees pending access requests for entries they own.
- All regular-user capabilities, plus:
- Can read all entry contents regardless of the
Usersfield. - Can edit all entries.
- Receives no special UI beyond this — there is no separate admin panel.
The Users field on each entry controls who can read its contents. The field is evaluated as follows (first matching rule wins):
Users value |
Who can read the contents |
|---|---|
null or empty |
Private — owner and admins only (default) |
"*" |
Everyone |
"alice, bob" |
alice and bob only (plus owner and admins) |
Regardless of the Users field, the following always have read access:
- The entry owner (
CreatedByfield). - Any user with
IsAdmin: true.
All users can always see the entry title in the listing — only the contents are restricted.
Only users who can read an entry may edit it. This prevents direct URL navigation to /Entries/Edit/{id} from bypassing access control.
Every create or update operation appends a snapshot to the entry's History array. Each snapshot captures the complete state at that moment:
{
"ChangedAtUtc": "2026-03-08T12:00:00Z",
"ChangedBy": "alice",
"Title": "Production DB",
"Details": "host=db.example.com\npassword=newpassword",
"Users": "alice, bob"
}The Details page shows the full change history in a collapsible section, newest version first. If the title changed between versions, the old title is highlighted.
The current entry always reflects the latest version. History is read-only — individual history entries cannot be deleted or edited through the UI.
When a user tries to open an entry they cannot read, they see an Access restricted panel instead of the contents. The panel shows who owns the entry and offers a Request access button.
┌─────────────────────────────────────────────────┐
│ 🔒 Access restricted │
│ │
│ This entry is owned by alice. │
│ You can request access and the owner will │
│ be notified. │
│ │
│ [ Request access ] │
└─────────────────────────────────────────────────┘
Clicking Request access creates a record in requests.json. If a request is already pending, the button is replaced with a notice:
⏳ Request sent — waiting for alice to approve.
A user can have at most one pending request per entry. Submitting again (e.g. by reloading) is silently ignored.
The next time the entry owner loads any page, a red notification badge appears on their avatar in the sidebar footer, showing the count of pending requests. A highlighted link appears below the avatar reading "N pending requests".
Clicking the link opens /Requests, which lists every pending request for entries the current user owns:
┌──────────────────────────────────────────────────────────────┐
│ C charlie │
│ requested access to Production DB │
│ 2026-03-08 11:30 UTC │
│ [ ✓ Approve ] [ ✗ Decline]│
└──────────────────────────────────────────────────────────────┘
When the owner clicks Approve:
- The requesting user's username is appended to the entry's
Usersfield. - The request record is removed from
requests.json. - The user can now read the entry on their next visit.
If the entry's Users field is already null or "*" (everyone has access), the request is simply removed — no change to the entry is needed.
When the owner clicks Decline:
- The request record is removed from
requests.json. - The entry's
Usersfield is not changed — the user still cannot read the entry. - The user may submit a new request in the future (there is no permanent block).
On first run (or whenever a data file is missing), the application creates default data:
| File | Default content |
|---|---|
users.json |
admin / admin123 (IsAdmin: true), test / test |
entries.json |
One sample entry titled "First Entry" owned by admin |
access.json |
Empty array |
requests.json |
Empty array |
Change the default passwords before exposing the application to any network.
When the application starts it runs three migration passes in order, each of which is a no-op if the data is already up to date:
| Migration | What it does |
|---|---|
MigrateEntries |
Adds a synthetic (pre-history) history record (with ChangedAtUtc = DateTime.MinValue) to any entry that has no history, preserving the entry's current field values. |
MigrateUsers |
Sets IsAdmin = true on any user whose username is "admin" if the flag is missing or false. |
MigrateEntryOwners |
Assigns CreatedBy to entries where the field is empty. The default owner is the first admin user found in users.json; if no admin exists, the string "admin" is used as a fallback. |
These migrations allow the application to be upgraded in place without manually editing the JSON files.
SecretsManager2/
├── App_Data/ JSON data files (auto-created at runtime)
│ ├── users.json
│ ├── entries.json
│ ├── access.json
│ └── requests.json
├── Controllers/
│ ├── AccountController.cs Login / logout
│ ├── EntriesController.cs Entry CRUD + access request submission
│ └── RequestsController.cs Approve / decline access requests
├── Models/
│ ├── User.cs
│ ├── Entry.cs
│ ├── EntryHistoryRecord.cs
│ ├── AccessRequest.cs
│ ├── AccessLogEntry.cs
│ ├── EntryListItemViewModel.cs
│ ├── EntryDetailsViewModel.cs
│ ├── AccessRequestViewModel.cs
│ ├── EntryCreateViewModel.cs
│ └── EntryEditViewModel.cs
├── Services/
│ └── JsonDataStore.cs All data access, access control logic, migrations
├── Views/
│ ├── Account/Login.cshtml
│ ├── Entries/
│ │ ├── Index.cshtml Entry list (all entries, locked ones visually distinguished)
│ │ ├── Details.cshtml Entry detail, access-denied panel, change history
│ │ ├── Create.cshtml
│ │ └── Edit.cshtml
│ ├── Requests/
│ │ └── Index.cshtml Pending requests inbox for entry owners
│ └── Shared/_Layout.cshtml Sidebar, notification badge
└── wwwroot/css/site.css Dark theme, all component styles