Restrict registration credentials by portal type and simplify vault form

This commit is contained in:
A R R R Associates
2026-09-02 15:17:39 +05:30
parent 7702cf8f6b
commit 1ce2fd070e
2 changed files with 159 additions and 46 deletions
@@ -4,55 +4,109 @@
<div class="mb-5">
<h1 class="text-2xl font-bold">Add encrypted credential</h1>
{% if selected_registration %}
<p class="mt-1 text-sm text-slate-600">Linked to registration <strong>{{ selected_registration.registration_number }}</strong>. Secrets remain encrypted and reveal access is audited.</p>
<p class="mt-1 text-sm text-slate-600">
Add a portal credential for this registration. Secrets remain encrypted and every reveal is audited.
</p>
{% else %}
<p class="mt-1 text-sm text-slate-600">Add a firm or client credential. Secrets remain encrypted and every reveal is audited.</p>
{% endif %}
</div>
<form method="post" class="grid gap-4 rounded-xl bg-white p-6 shadow sm:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="sm:col-span-2">Title<input required name="title" class="mt-1 w-full rounded-lg border p-2" placeholder="e.g. GST Portal - Main Login"></label>
<label>Credential type
<select name="category" class="mt-1 w-full rounded-lg border p-2">
<option value="gst_portal">GST Portal</option>
<option value="income_tax_portal">Income Tax Portal</option>
<option value="traces_tds">TRACES / TDS</option>
<option value="mca_portal">MCA Portal</option>
<option value="eway_bill">E-Way Bill</option>
<option value="einvoice">E-Invoice</option>
<option value="government_portal">Other Government Portal</option>
<option value="banking">Banking</option>
<option value="email">Email</option>
<option value="software">Software</option>
<option value="api_key">API key</option>
<option value="digital_signature">Digital signature</option>
<option value="other">Other</option>
</select>
</label>
<label>Sensitivity<select name="sensitivity" class="mt-1 w-full rounded-lg border p-2"><option>high</option><option>critical</option><option>standard</option></select></label>
<label>Client
<select name="client_id" id="vault-client" class="mt-1 w-full rounded-lg border p-2">
<option value="">Firm-level credential</option>
{% for c in clients %}<option value="{{ c.id }}" {% if selected_client_id and c.id == selected_client_id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}
</select>
</label>
<label>Registration record
<select name="registration_id" id="vault-registration" class="mt-1 w-full rounded-lg border p-2">
<option value="">Not linked</option>
{% for r in registrations %}<option value="{{ r.id }}" data-client-id="{{ r.client_id }}" {% if selected_registration_id and r.id == selected_registration_id %}selected{% endif %}>{{ r.registration_number }}</option>{% endfor %}
</select>
</label>
<label class="sm:col-span-2">Portal URL<input name="portal_url" type="url" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Reference number<input name="reference_number" class="mt-1 w-full rounded-lg border p-2" placeholder="GSTIN / TAN / CIN / login reference"></label>
<label>Username/login ID<input name="username" autocomplete="off" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Secret/password/token<input required name="secret" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Additional secret/PIN<input name="additional_secret" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Expires on<input name="expires_on" type="date" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Rotation due on<input name="rotation_due_on" type="date" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Owner<select name="owner_user_id" class="mt-1 w-full rounded-lg border p-2"><option value="">Current user</option>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></label>
<fieldset><legend>Explicit staff access</legend><div class="mt-1 max-h-32 overflow-auto rounded-lg border p-2">{% for u in users %}<label class="block text-sm"><input type="checkbox" name="allowed_user_ids" value="{{ u.id }}"> {{ u.full_name or u.email }}</label>{% endfor %}</div></fieldset>
<label class="sm:col-span-2">Encrypted notes<textarea name="notes" rows="3" class="mt-1 w-full rounded-lg border p-2"></textarea></label>
<div class="sm:col-span-2 flex gap-2"><button class="rounded-lg bg-brand-600 px-4 py-2 font-semibold text-white">Save securely</button><a href="/credential-vault" class="rounded-lg border px-4 py-2">Cancel</a></div>
{% if selected_registration %}
<input type="hidden" name="client_id" value="{{ selected_registration.client_id }}">
<input type="hidden" name="registration_id" value="{{ selected_registration.id }}">
<input type="hidden" name="sensitivity" value="high">
<div class="rounded-lg border bg-slate-50 p-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Client</div>
<div class="mt-1 font-semibold text-slate-900">{{ selected_client.client_name if selected_client else selected_registration.client_id }}</div>
</div>
<div class="rounded-lg border bg-slate-50 p-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Registration</div>
<div class="mt-1 font-semibold text-slate-900">{{ selected_registration.registration_number }}</div>
<div class="mt-1 text-xs text-slate-500">{{ selected_registration_type.name if selected_registration_type else 'Registration' }}</div>
</div>
<label>Portal / Credential type
<select name="category" required class="mt-1 w-full rounded-lg border p-2">
{% for code, label in credential_types %}
<option value="{{ code }}">{{ label }}</option>
{% endfor %}
</select>
<span class="mt-1 block text-xs text-slate-500">Only portals valid for this registration type are shown.</span>
</label>
<label>Title
<input required name="title" class="mt-1 w-full rounded-lg border p-2" placeholder="e.g. Main login / Deductor login">
</label>
<label>Username / Login ID
<input name="username" autocomplete="off" class="mt-1 w-full rounded-lg border p-2">
</label>
<label>Secret / Password / Token
<input required name="secret" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border p-2">
</label>
<label>Additional PIN / Secret <span class="text-xs text-slate-500">(optional)</span>
<input name="additional_secret" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border p-2">
</label>
<label class="sm:col-span-2">Remarks <span class="text-xs text-slate-500">(optional)</span>
<textarea name="notes" rows="3" class="mt-1 w-full rounded-lg border p-2"></textarea>
</label>
<!-- Keep the existing POST contract without exposing irrelevant generic-vault fields. -->
<input type="hidden" name="portal_url" value="">
<input type="hidden" name="reference_number" value="{{ selected_registration.registration_number }}">
<input type="hidden" name="expires_on" value="">
<input type="hidden" name="rotation_due_on" value="">
<input type="hidden" name="owner_user_id" value="{{ current_user.id }}">
{% else %}
<label class="sm:col-span-2">Title<input required name="title" class="mt-1 w-full rounded-lg border p-2" placeholder="e.g. GST Portal - Main Login"></label>
<label>Credential type
<select name="category" class="mt-1 w-full rounded-lg border p-2">
{% for code, label in credential_types %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
</select>
</label>
<label>Sensitivity<select name="sensitivity" class="mt-1 w-full rounded-lg border p-2"><option>high</option><option>critical</option><option>standard</option></select></label>
<label>Client
<select name="client_id" id="vault-client" class="mt-1 w-full rounded-lg border p-2">
<option value="">Firm-level credential</option>
{% for c in clients %}<option value="{{ c.id }}" {% if selected_client_id and c.id == selected_client_id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}
</select>
</label>
<label>Registration record
<select name="registration_id" id="vault-registration" class="mt-1 w-full rounded-lg border p-2">
<option value="">Not linked</option>
{% for r in registrations %}<option value="{{ r.id }}" data-client-id="{{ r.client_id }}" {% if selected_registration_id and r.id == selected_registration_id %}selected{% endif %}>{{ r.registration_number }}</option>{% endfor %}
</select>
</label>
<label class="sm:col-span-2">Portal URL<input name="portal_url" type="url" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Reference number<input name="reference_number" class="mt-1 w-full rounded-lg border p-2" placeholder="GSTIN / TAN / CIN / login reference"></label>
<label>Username/login ID<input name="username" autocomplete="off" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Secret/password/token<input required name="secret" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Additional secret/PIN<input name="additional_secret" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Expires on<input name="expires_on" type="date" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Rotation due on<input name="rotation_due_on" type="date" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Owner<select name="owner_user_id" class="mt-1 w-full rounded-lg border p-2"><option value="">Current user</option>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></label>
<fieldset><legend>Explicit staff access</legend><div class="mt-1 max-h-32 overflow-auto rounded-lg border p-2">{% for u in users %}<label class="block text-sm"><input type="checkbox" name="allowed_user_ids" value="{{ u.id }}"> {{ u.full_name or u.email }}</label>{% endfor %}</div></fieldset>
<label class="sm:col-span-2">Encrypted notes<textarea name="notes" rows="3" class="mt-1 w-full rounded-lg border p-2"></textarea></label>
{% endif %}
<div class="sm:col-span-2 flex gap-2">
<button class="rounded-lg bg-brand-600 px-4 py-2 font-semibold text-white">Save securely</button>
{% if selected_registration %}
<a href="/registrations/client/{{ selected_registration.client_id }}" class="rounded-lg border px-4 py-2">Cancel</a>
{% else %}
<a href="/credential-vault" class="rounded-lg border px-4 py-2">Cancel</a>
{% endif %}
</div>
</form>
</div>
{% if not selected_registration %}
<script>
(() => {
const client = document.getElementById('vault-client');
@@ -71,4 +125,5 @@
filterRegistrations();
})();
</script>
{% endif %}
{% endblock %}
+61 -3
View File
@@ -15,10 +15,45 @@ from app.modules.core.iam.models import User
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.credential_vault.models import CredentialVaultAccessLog, CredentialVaultEntry, CredentialVaultVersion
from app.modules.credential_vault.service import active_branch_id, active_tenant_id, can_manage_vault, can_open_vault, can_view_entry, create_entry, due_state, list_visible_entries, log_access, reveal_entry, rotate_entry
from app.modules.registrations.models import ClientRegistration
from app.modules.registrations.models import ClientRegistration, RegistrationType
router = APIRouter(prefix="/credential-vault", tags=["credential-vault-ui"])
REGISTRATION_CREDENTIAL_TYPES: dict[str, tuple[tuple[str, str], ...]] = {
"PAN": (("income_tax_portal", "Income Tax Portal"), ("other", "Other")),
"TAN": (("income_tax_portal", "Income Tax Portal / TDS"), ("traces_tds", "TRACES / TDS"), ("other", "Other")),
"GSTIN": (("gst_portal", "GST Portal"), ("eway_bill", "E-Way Bill"), ("einvoice", "E-Invoice"), ("api_key", "GST API / Provider"), ("other", "Other")),
"CIN": (("mca_portal", "MCA Portal"), ("other", "Other")),
"LLPIN": (("mca_portal", "MCA Portal"), ("other", "Other")),
"DSC": (("digital_signature", "Digital Signature / Token"), ("other", "Other")),
"UDYAM": (("government_portal", "Udyam / MSME Portal"), ("other", "Other")),
"FSSAI": (("government_portal", "FSSAI Portal"), ("other", "Other")),
}
GENERIC_CREDENTIAL_TYPES: tuple[tuple[str, str], ...] = (
("gst_portal", "GST Portal"),
("income_tax_portal", "Income Tax Portal"),
("traces_tds", "TRACES / TDS"),
("mca_portal", "MCA Portal"),
("eway_bill", "E-Way Bill"),
("einvoice", "E-Invoice"),
("government_portal", "Other Government Portal"),
("banking", "Banking"),
("email", "Email"),
("software", "Software"),
("api_key", "API key"),
("digital_signature", "Digital signature"),
("other", "Other"),
)
def _credential_types_for_registration_type(type_code: str | None) -> tuple[tuple[str, str], ...]:
code = (type_code or "").strip().upper()
return REGISTRATION_CREDENTIAL_TYPES.get(code, (("government_portal", "Government / Registration Portal"), ("other", "Other")))
def _category_label(category: str, choices: tuple[tuple[str, str], ...]) -> str:
return dict(choices).get(category, category.replace("_", " ").title())
def _date(value: str) -> date | None:
return date.fromisoformat(value) if value else None
@@ -61,18 +96,26 @@ def new_entry(request: Request, client_id: int | None = None, registration_id: i
users = db.execute(select(User).where(User.tenant_id == tenant_id, User.is_active.is_(True)).order_by(User.full_name)).scalars().all()
registrations = db.execute(select(ClientRegistration).where(ClientRegistration.tenant_id == tenant_id).order_by(ClientRegistration.registration_number)).scalars().all()
selected_registration = None
selected_registration_type = None
selected_client = None
credential_types = GENERIC_CREDENTIAL_TYPES
if registration_id:
selected_registration = db.get(ClientRegistration, int(registration_id))
if not selected_registration or selected_registration.tenant_id != tenant_id:
raise HTTPException(404, "Registration record was not found in this audit firm.")
client_id = int(selected_registration.client_id)
if client_id and not any(int(c.id) == int(client_id) for c in clients):
raise HTTPException(404, "Client was not found in this audit firm.")
selected_registration_type = db.get(RegistrationType, selected_registration.registration_type_id)
credential_types = _credential_types_for_registration_type(getattr(selected_registration_type, "code", None))
if client_id:
selected_client = next((c for c in clients if int(c.id) == int(client_id)), None)
if not selected_client:
raise HTTPException(404, "Client was not found in this audit firm.")
return templates.TemplateResponse(
"modules/credential_vault/templates/credential_vault/form.html",
_ctx(
request, user, db, entry=None, clients=clients, users=users, registrations=registrations, branch_id=branch_id,
selected_client_id=client_id, selected_registration_id=registration_id, selected_registration=selected_registration,
selected_registration_type=selected_registration_type, selected_client=selected_client, credential_types=credential_types,
),
)
@@ -92,6 +135,21 @@ async def save_new(request: Request, title: str=Form(...), category: str=Form("g
if selected_client_id and int(registration.client_id) != selected_client_id:
raise HTTPException(400, "Selected registration does not belong to the selected client.")
selected_client_id = int(registration.client_id)
registration_type = db.get(RegistrationType, registration.registration_type_id)
allowed_types = _credential_types_for_registration_type(getattr(registration_type, "code", None))
allowed_codes = {code for code, _label in allowed_types}
if category not in allowed_codes:
type_name = getattr(registration_type, "name", None) or getattr(registration_type, "code", None) or "this registration type"
raise HTTPException(400, f"{_category_label(category, GENERIC_CREDENTIAL_TYPES)} credentials cannot be linked to {type_name}.")
# Registration-linked credentials intentionally use the simpler office workflow.
# Generic metadata columns remain available for firm-level vault entries.
portal_url = ""
reference_number = registration.registration_number or ""
sensitivity = "high"
expires_on = ""
rotation_due_on = ""
owner_user_id = str(user.id)
allowed_user_ids = []
entry = create_entry(db, tenant_id=tenant_id, branch_id=active_branch_id(request, user), client_id=selected_client_id, registration_id=selected_registration_id, title=title, category=category, portal_url=portal_url, reference_number=reference_number, username=username, secret=secret, additional_secret=additional_secret, notes=notes, sensitivity=sensitivity, expires_on=_date(expires_on), rotation_due_on=_date(rotation_due_on), owner_user_id=int(owner_user_id) if owner_user_id else user.id, allowed_user_ids_csv=",".join(allowed_user_ids), actor_user_id=user.id)
log_access(db, request, user, entry, "create", reason="Credential created"); db.commit()
return RedirectResponse(f"/credential-vault/{entry.id}", 303)